commit ed8a3145b3d918159bbee76441a623708476b407 Author: dtoro Date: Sat Aug 15 22:20:54 2026 +0200 oikos-web: extract the client stack from dtoro/oikos Phase 1 of the hexagonal-architecture plan (dtoro/oikos plans/2026-08-15-hexagonal-architecture.md). Moves the delivery stack for the control-room UI into its own repo with its own pipeline: - web/ — Svelte 5 SPA, verbatim (vendor/ included) - desktop/ — Wails v3 wrapper, updateURL repointed to dtoro/oikos-web releases - compose/ — Dockerfile + Caddyfile, verbatim (the /wails/* 404 and asset no-fallback quirks are load-bearing) - docker-compose.yml — single web service, same 8091:80 publish, mem/cpu limits, and restart policy as the oikos stack's web service - scripts/deploy.sh — mirrors oikos deploy essentials: CI-green gate, TOCTOU guard, version-tagged oikos-web:v$VERSION, prune to 3 - cmd/webhook + scripts/install-webhook.sh — standalone push-to-deploy receiver on :9798 (env-only secrets, no Infisical dependency) - CI: the web job from oikos's ci.yml + the desktop build/release workflow, path-adjusted Own VERSION (0.33.0) with the same bump-on-main rule; starts above oikos's 0.32.x so the desktop updater sees an upgrade. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..11b3389 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,36 @@ +# oikos-web CI (Gitea Actions) — SPA pipeline, mirrors the web job this +# repo inherited from dtoro/oikos. +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - name: svelte-check (advisory — baseline not yet clean) + run: npm run check + continue-on-error: true + - name: eslint (advisory — baseline not yet clean) + run: npm run lint + continue-on-error: true + - name: prettier format check (advisory — baseline not yet clean) + run: npm run format:check + continue-on-error: true + - name: test + run: npm run test + - name: build + run: npm run build diff --git a/.gitea/workflows/desktop.yml b/.gitea/workflows/desktop.yml new file mode 100644 index 0000000..08cca58 --- /dev/null +++ b/.gitea/workflows/desktop.yml @@ -0,0 +1,70 @@ +name: Desktop App +on: + push: + branches: + - main + tags: + - 'desktop-*' + - 'v[0-9]+.[0-9]+.[0-9]*' + +jobs: + build: + name: Build Linux (amd64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - run: npm ci + working-directory: web + + - run: npm run build + working-directory: web + + - run: | + rm -rf desktop/frontend/dist + mkdir -p desktop/frontend/dist + cp -r web/dist/* desktop/frontend/dist/ + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev + + - run: CGO_ENABLED=1 go build -o build/bin/Oikos . + working-directory: desktop + + - run: | + cd desktop/build/bin + tar czf oikos-desktop-linux-amd64.tar.gz Oikos + sha256sum oikos-desktop-linux-amd64.tar.gz > oikos-desktop-linux-amd64.tar.gz.sha256 + + - uses: actions/upload-artifact@v4 + with: + name: oikos-desktop-linux-amd64 + path: | + desktop/build/bin/oikos-desktop-linux-amd64.tar.gz + desktop/build/bin/oikos-desktop-linux-amd64.tar.gz.sha256 + + release: + name: Attach to Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/download-artifact@v4 + with: + name: oikos-desktop-linux-amd64 + + - uses: https://gitea.com/actions/release-action@v1 + with: + files: | + oikos-desktop-linux-amd64.tar.gz + oikos-desktop-linux-amd64.tar.gz.sha256 + api_key: ${{ secrets.GITEA_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25ab89b --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store + +# Binaries +webhook +oikos-desktop + +.env + +# Web SPA build artifacts +web/dist/ +web/node_modules/ + +# Wails desktop app — frontend copy for embedding, build output +desktop/frontend/dist/ +desktop/build/ +desktop/Oikos diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..87305c8 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +# oikos-web — control-room SPA + desktop app delivery +# +# Extracted from dtoro/oikos (plans/2026-08-15-hexagonal-architecture.md +# Phase 1). The backend API/MCP stack stays in dtoro/oikos; this repo owns +# everything that renders it. + +.PHONY: ui desktop desktop-package install webhook clean + +# Local sanity-check build of the SPA. +ui: + cd web && npm run build + +desktop: ui ## Build the Wails desktop app for the current platform + rm -rf desktop/frontend/dist + mkdir -p desktop/frontend/dist + cp -r web/dist/* desktop/frontend/dist/ + cd desktop && CGO_ENABLED=1 go build -tags desktop -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos . + +desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux) + @if [ "$(shell uname)" = "Darwin" ]; then \ + mkdir -p desktop/build/bin/Oikos.app/Contents/MacOS desktop/build/bin/Oikos.app/Contents/Resources; \ + APP="desktop/build/bin/Oikos.app"; \ + cp desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \ + cp desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \ + sed "s/\$$(VERSION)/$$(cat VERSION)/" desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \ + cd desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \ + else \ + cd desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \ + fi + @echo "Package: desktop/build/bin/" + +install: desktop-package ## Install to /Applications (macOS) + rm -rf /Applications/Oikos.app + cp -r desktop/build/bin/Oikos.app /Applications/ + +webhook: ## Build the deploy-webhook receiver + go build -o webhook ./cmd/webhook + +clean: + rm -rf web/dist desktop/build desktop/frontend/dist webhook diff --git a/README.md b/README.md new file mode 100644 index 0000000..24c0975 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# oikos-web + +The Oikos control-room client: the Svelte 5 SPA (`web/`), the Wails v3 +desktop wrapper (`desktop/`), and the delivery stack that serves it +(`compose/`, `docker-compose.yml`, `scripts/deploy.sh`). + +Extracted from [dtoro/oikos](https://git.hubris.network/dtoro/oikos) in +Phase 1 of the hexagonal-architecture refactor +(plans/2026-08-15-hexagonal-architecture.md in that repo). The backend +(API, MCP, scheduler) stays in dtoro/oikos — this repo talks to it over +REST/SSE only. + +## Layout + +``` +web/ Control-room SPA (Svelte 5, Vite) +desktop/ Wails v3 desktop wrapper (macOS + Linux) +compose/ Dockerfile + Caddyfile for the oikos-web image +cmd/webhook/ Deploy-webhook receiver (Gitea push → deploy) +scripts/deploy.sh Deploy script (CI-green gate, versioned images) +docker-compose.yml The single `web` service, publishing 8091:80 +``` + +## Dev + +```bash +cd web && OIKOS_API_TOKEN=dev-token npm run dev # proxies to api :8090/:8092 +make desktop # build the desktop app +``` + +The SPA's first-launch Config screen stores the server URL + token; the +backend version is available via the API ping — the sidebar shows this +repo's VERSION. + +## Versioning + +Own `VERSION` file with the same rule as oikos: every commit to `main` +bumps it (patch for fixes, minor for features). The version tag is shown +in the SPA sidebar and stamped into desktop builds. + +## Deploy + +Push to `main` → Gitea webhook → `cmd/webhook` receiver → +`scripts/deploy.sh`: waits for CI green, builds `oikos-web:v$VERSION` from +the working tree, `docker compose up -d`, prunes to the 3 newest tags. +Rollback: `OIKOS_VERSION=v0.x.y docker compose up -d`. + +Desktop auto-update reads this repo's Gitea releases. Builds installed +before the extraction point at dtoro/oikos and need one manual reinstall +(see the first release notes). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..be386c9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.33.0 diff --git a/compose/Caddyfile b/compose/Caddyfile new file mode 100644 index 0000000..57a7356 --- /dev/null +++ b/compose/Caddyfile @@ -0,0 +1,27 @@ +:80 { + root * /srv + + # /wails/runtime.js is injected by the Wails desktop wrapper, which serves + # the same dist/ from its own asset handler. In a browser it does not + # exist, and the SPA fallback below answered it with index.html — so the + # browser parsed "" as JavaScript and threw + # "SyntaxError: expected expression, got '<'" on every page load. + # Return a real 404 instead: the tag fails quietly, and the desktop app is + # unaffected because it never reaches this server. + handle /wails/* { + error 404 + } + + # Same reasoning for any other asset: a missing .js/.css/.map answered with + # HTML is always a confusing parse error rather than an honest 404. Only + # real routes should fall through to the SPA. + @asset path_regexp \.(js|mjs|css|map|json|png|jpg|svg|ico|woff2?)$ + handle @asset { + file_server + } + + handle { + file_server + try_files {path} /index.html + } +} diff --git a/compose/Dockerfile b/compose/Dockerfile new file mode 100644 index 0000000..afbff7a --- /dev/null +++ b/compose/Dockerfile @@ -0,0 +1,20 @@ +# Dockerfile for the oikos control-room SPA. Built separately from the +# oikos binary (compose/oikos/Dockerfile) — see docker-compose.yml's `web` +# service. The outer production Caddy (caddy-conf repo, LXC 121) handles +# Authentik + splits /api/*, /mcp, /agent/* off to the api service; this +# container only serves static files with SPA-fallback routing. + +FROM node:22-alpine AS builder + +WORKDIR /build/web +COPY web/package.json web/package-lock.json ./ +COPY web/vendor /build/vendor +RUN npm install --no-audit --no-fund +COPY VERSION ./ +COPY web/ ./ +RUN npm run build + +FROM caddy:2-alpine + +COPY --from=builder /build/web/dist /srv +COPY compose/web/Caddyfile /etc/caddy/Caddyfile diff --git a/desktop/Info.plist.template b/desktop/Info.plist.template new file mode 100644 index 0000000..5c153df --- /dev/null +++ b/desktop/Info.plist.template @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Oikos + CFBundleIdentifier + com.hubris.oikos-desktop + CFBundleIconFile + icon + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Oikos + CFBundlePackageType + APPL + CFBundleShortVersionString + $(VERSION) + CFBundleVersion + $(VERSION) + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © 2026 Hubris. All rights reserved. + + diff --git a/desktop/Taskfile.yml b/desktop/Taskfile.yml new file mode 100644 index 0000000..0548f72 --- /dev/null +++ b/desktop/Taskfile.yml @@ -0,0 +1,14 @@ +version: '3' + +tasks: + build: + summary: Build the Oikos desktop app + cmds: + - go build -o build/bin/Oikos . + env: + CGO_ENABLED: 1 + + dev: + summary: Run in development mode + cmds: + - go run . diff --git a/desktop/assets_embed.go b/desktop/assets_embed.go new file mode 100644 index 0000000..9c207be --- /dev/null +++ b/desktop/assets_embed.go @@ -0,0 +1,12 @@ +//go:build desktop + +package main + +import "embed" + +// assets is the embedded web SPA. Built only with the `desktop` tag, which is +// set by `make desktop` after it copies web/dist/* into cmd/desktop/frontend/dist +// (a gitignored build artifact). See assets_stub.go for the default build. +// +//go:embed frontend/dist +var assets embed.FS diff --git a/desktop/assets_stub.go b/desktop/assets_stub.go new file mode 100644 index 0000000..dd23dc1 --- /dev/null +++ b/desktop/assets_stub.go @@ -0,0 +1,12 @@ +//go:build !desktop + +package main + +import "embed" + +// assets is an empty FS for the default (non-desktop) build. The real embedded +// SPA lives in assets_embed.go behind the `desktop` build tag, because +// frontend/dist is a gitignored artifact that only exists after `make desktop` +// copies web/dist/* into it. This stub lets `go build ./...` compile cleanly on +// a fresh checkout without the frontend built. +var assets embed.FS diff --git a/desktop/entitlements.plist b/desktop/entitlements.plist new file mode 100644 index 0000000..0b7860f --- /dev/null +++ b/desktop/entitlements.plist @@ -0,0 +1,26 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + $(AppIdentifierPrefix)com.hubris.oikos-desktop + + + diff --git a/desktop/icon.icns b/desktop/icon.icns new file mode 100644 index 0000000..86efec7 Binary files /dev/null and b/desktop/icon.icns differ diff --git a/desktop/icon.png b/desktop/icon.png new file mode 100644 index 0000000..e89d16d Binary files /dev/null and b/desktop/icon.png differ diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 0000000..3b5e18c --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,790 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + _ "embed" // required by the //go:embed icon.png directive below + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/zalando/go-keyring" +) + +// assets is the embedded web SPA, defined in assets_embed.go (`desktop` build +// tag, real //go:embed frontend/dist) and assets_stub.go (default build, empty +// FS). frontend/dist is a gitignored artifact populated by `make desktop`; the +// stub keeps `go build ./...` working on a clean checkout. + +//go:embed icon.png +var iconPNG []byte + +const ( + keyringService = "com.hubris.oikos-desktop" + keyringUser = "oikos" + updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos-web/releases" + pollInterval = 30 * time.Second + updateInterval = 6 * time.Hour + oidcCallbackPort = 18901 +) + +// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)" +// (Makefile desktop target). The default keeps a non-empty fallback for +// `go build ./cmd/desktop` without ldflags. +var version = "0.1.0-dev" + +type OikosConfig struct { + ApiUrl string `json:"apiUrl"` + Token string `json:"token,omitempty"` + IsDesktop bool `json:"isDesktop"` +} + +// ---- ConfigService ---- + +type ConfigService struct{} + +func (c *ConfigService) Name() string { return "config" } + +func (c *ConfigService) SaveConfig(apiUrl, token string) error { + cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true} + data, _ := json.Marshal(cfg) + return keyring.Set(keyringService, keyringUser, string(data)) +} + +func (c *ConfigService) ClearConfig() error { + return keyring.Delete(keyringService, keyringUser) +} + +func (c *ConfigService) GetStoredConfig() *OikosConfig { + return loadConfig() +} + +func (c *ConfigService) EnableAutoStart() error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("autostart not supported on %s", runtime.GOOS) + } + usr, _ := user.Current() + dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents") + os.MkdirAll(dir, 0755) + + exe, _ := os.Executable() + plist := fmt.Sprintf(` + + + + Label + com.hubris.oikos-desktop + ProgramArguments + + %s + + RunAtLoad + + KeepAlive + + +`, exe) + + return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644) +} + +func (c *ConfigService) DisableAutoStart() error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("autostart not supported on %s", runtime.GOOS) + } + usr, _ := user.Current() + path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist") + return os.Remove(path) +} + +// ---- Local OIDC server (runs alongside the webview) ---- + +type oidcSession struct { + apiUrl string + verifier string + state string + ch chan string +} + +var ( + oidcSessionsMu sync.Mutex + oidcSessions = make(map[string]*oidcSession) +) + +func startOIDCServer() *http.Server { + mux := http.NewServeMux() + + cors := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + } + } + + h := func(path string, handler func(http.ResponseWriter, *http.Request)) { + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + cors(w, r) + if r.Method == "OPTIONS" { + return + } + handler(w, r) + }) + } + + h("/oidc/start", func(w http.ResponseWriter, r *http.Request) { + apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/") + returnURL := r.URL.Query().Get("ret") + if apiUrl == "" { + http.Error(w, "apiUrl required", http.StatusBadRequest) + return + } + if returnURL == "" { + returnURL = "/?desktop=1" + } + + oidcCfg, err := fetchOIDCConfig(apiUrl) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + verifier, challenge, _ := pkceParams() + state := randomString(32) + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort) + + ch := make(chan string, 1) + oidcSessionsMu.Lock() + sessionID := randomString(16) + oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch} + oidcSessionsMu.Unlock() + + authURL := fmt.Sprintf("%s?%s", + oidcCfg.AuthorizationEndpoint, + url.Values{ + "response_type": {"code"}, + "client_id": {oidcCfg.ClientID}, + "redirect_uri": {redirectURI}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + "scope": {"openid profile email"}, + }.Encode(), + ) + + exec.Command("open", authURL).Start() + + select { + case token := <-ch: + if token != "" { + c := &ConfigService{} + c.SaveConfig(apiUrl, token) + returnURL += "&token=" + url.QueryEscape(token) + } + case <-time.After(5 * time.Minute): + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `Oikos + + +

Connected

Redirecting back to Oikos…

`, returnURL) + }) + + h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + gotState := r.URL.Query().Get("state") + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + oidcSessionsMu.Lock() + var session *oidcSession + var sessionID string + for id, s := range oidcSessions { + if s.state == gotState { + session = s + sessionID = id + break + } + } + oidcSessionsMu.Unlock() + + if session == nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid state.")) + return + } + + token, err := exchangeCode( + session.apiUrl, + code, session.verifier, + fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort), + ) + + oidcSessionsMu.Lock() + delete(oidcSessions, sessionID) + oidcSessionsMu.Unlock() + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Token exchange failed: %v", err) + session.ch <- "" + return + } + + w.Write([]byte(`Oikos + +

Connected

You can close this window and return to Oikos.

`)) + session.ch <- token + }) + + mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) { + apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/") + if apiUrl == "" { + http.Error(w, "apiUrl required", http.StatusBadRequest) + return + } + cfg, err := fetchOIDCConfig(apiUrl) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cfg) + }) + + mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) { + latest := fetchLatestRelease() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + if latest == nil { + json.NewEncoder(w).Encode(map[string]string{"current": version}) + return + } + hasAsset := false + for _, a := range latest.Assets { + if strings.Contains(a.Name, "darwin") { + hasAsset = true + updater.mu.Lock() + updater.latestURL = a.BrowserDownloadURL + updater.mu.Unlock() + break + } + } + json.NewEncoder(w).Encode(map[string]string{ + "current": version, + "latest": latest.Version, + "has_asset": fmt.Sprintf("%t", hasAsset), + }) + }) + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort)) + if err != nil { + log.Printf("OIDC server: %v", err) + return nil + } + log.Printf("OIDC server listening on %s", listener.Addr()) + srv := &http.Server{Handler: mux} + go srv.Serve(listener) + return srv +} + +// ---- Window persistence ---- + +type windowState struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + +func windowStatePath() string { + usr, _ := user.Current() + return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json") +} + +func loadWindowState() *windowState { + data, err := os.ReadFile(windowStatePath()) + if err != nil { + return nil + } + var ws windowState + if err := json.Unmarshal(data, &ws); err != nil { + return nil + } + if ws.Width < 200 || ws.Height < 200 { + return nil + } + return &ws +} + +func saveWindowState(w application.Window) { + x, y := w.Position() + width, height := w.Size() + ws := windowState{X: x, Y: y, Width: width, Height: height} + data, _ := json.Marshal(ws) + + usr, _ := user.Current() + dir := filepath.Join(usr.HomeDir, ".config", "oikos") + os.MkdirAll(dir, 0755) + os.WriteFile(filepath.Join(dir, "window.json"), data, 0644) +} + +func loadConfig() *OikosConfig { + data, err := keyring.Get(keyringService, keyringUser) + if err != nil { + return nil + } + var cfg OikosConfig + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + return nil + } + cfg.IsDesktop = true + return &cfg +} + +type oidcConfig struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + AuthorizationEndpoint string `json:"authorization_endpoint"` +} + +func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) { + resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("server returned %d", resp.StatusCode) + } + var cfg oidcConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func pkceParams() (verifier, challenge string, _ error) { + v := randomString(64) + h := sha256.Sum256([]byte(v)) + return v, base64.RawURLEncoding.EncodeToString(h[:]), nil +} + +func randomString(n int) string { + b := make([]byte, n) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) { + body, _ := json.Marshal(map[string]string{ + "grant_type": "authorization_code", + "code": code, + "code_verifier": verifier, + "redirect_uri": redirectURI, + }) + + resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body))) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b)) + } + + var tokens struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { + return "", err + } + if tokens.AccessToken == "" { + return "", fmt.Errorf("no access_token in response") + } + return tokens.AccessToken, nil +} + +// ---- Notifications ---- + +type dashboardSummary struct { + ApprovalsPending int `json:"approvals_pending"` + Signals struct { + Critical int `json:"critical"` + } `json:"signals_by_severity"` +} + +func (d *dashboardSummary) alertCount() int { + return d.ApprovalsPending + d.Signals.Critical +} + +func notify(title, subtitle string) { + if runtime.GOOS != "darwin" { + return + } + script := fmt.Sprintf( + `display notification "%s" with title "%s" sound name "default"`, + strings.ReplaceAll(subtitle, `"`, `\"`), + strings.ReplaceAll(title, `"`, `\"`), + ) + exec.Command("osascript", "-e", script).Run() +} + +func pollDashboard(cfg *OikosConfig) { + if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" { + return + } + + var lastCount int + first := true + + for { + req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil) + if err != nil { + time.Sleep(pollInterval) + continue + } + req.Header.Set("Authorization", "Bearer "+cfg.Token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + time.Sleep(pollInterval) + continue + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var summary dashboardSummary + if err := json.Unmarshal(body, &summary); err != nil { + time.Sleep(pollInterval) + continue + } + + if first { + lastCount = summary.alertCount() + first = false + } else { + current := summary.alertCount() + if current > lastCount { + notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical)) + } + lastCount = current + } + + time.Sleep(pollInterval) + } +} + +// ---- Auto-update ---- + +type giteaRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +type updateState struct { + mu sync.Mutex + latestURL string +} + +var updater = &updateState{} + +// CheckForUpdates checks Gitea releases for a newer version. If found, stores +// the download URL and returns the latest version string (empty if current). +func (c *ConfigService) CheckForUpdates() string { + latest := fetchLatestRelease() + if latest == nil || latest.Version == version { + return "" + } + for _, a := range latest.Assets { + if strings.Contains(a.Name, "darwin") { + updater.mu.Lock() + updater.latestURL = a.BrowserDownloadURL + updater.mu.Unlock() + return latest.Version + } + } + return "" +} + +// InstallUpdate downloads the stored update, replaces the app, and restarts. +func (c *ConfigService) InstallUpdate() error { + updater.mu.Lock() + url := updater.latestURL + updater.mu.Unlock() + if url == "" { + return fmt.Errorf("no update available") + } + return doUpdate(url) +} + +type latestRelease struct { + Version string + Assets []struct { + Name string + BrowserDownloadURL string + } +} + +func fetchLatestRelease() *latestRelease { + resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1") + if err != nil { + return nil + } + defer resp.Body.Close() + + var releases []giteaRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 { + return nil + } + + r := releases[0] + v := strings.TrimPrefix(r.TagName, "v") + if v == version { + return nil + } + + lr := &latestRelease{Version: v} + for _, a := range r.Assets { + lr.Assets = append(lr.Assets, struct { + Name string + BrowserDownloadURL string + }{a.Name, a.BrowserDownloadURL}) + } + return lr +} + +func doUpdate(downloadURL string) error { + tmp, err := os.CreateTemp("", "oikos-update-*.zip") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + resp, err := http.Get(downloadURL) + if err != nil { + return err + } + defer resp.Body.Close() + + if _, err := io.Copy(tmp, resp.Body); err != nil { + return err + } + tmp.Close() + + extractDir, err := os.MkdirTemp("", "oikos-extract") + if err != nil { + return err + } + defer os.RemoveAll(extractDir) + + cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("unzip: %w: %s", err, out) + } + + newApp := filepath.Join(extractDir, "Oikos.app") + if _, err := os.Stat(newApp); err != nil { + return fmt.Errorf("extracted app not found: %w", err) + } + + currentApp := "/Applications/Oikos.app" + if _, err := os.Stat(currentApp); os.IsNotExist(err) { + if exe, err := os.Executable(); err == nil { + currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe))) + } + } + + script := fmt.Sprintf(`#!/bin/bash +sleep 2 +rm -rf "%s" +mv "%s" "%s" +open "%s" +rm "$0" +`, currentApp, newApp, currentApp, currentApp) + + scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return err + } + + app := application.Get() + exec.Command("open", scriptPath).Start() + if app != nil { + app.Quit() + } + + return nil +} + +func checkUpdates() { + for { + time.Sleep(updateInterval) + + latest := fetchLatestRelease() + if latest == nil { + continue + } + + for _, a := range latest.Assets { + if strings.Contains(a.Name, "darwin") { + updater.mu.Lock() + updater.latestURL = a.BrowserDownloadURL + updater.mu.Unlock() + + app := application.Get() + if app == nil { + continue + } + msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version) + app.Dialog.Info(). + SetTitle("Update Available"). + SetMessage(msg). + Show() + break + } + } + } +} + +// ---- Main ---- + +func main() { + oidcSrv := startOIDCServer() + defer oidcSrv.Close() + + distFS, err := fs.Sub(assets, "frontend/dist") + if err != nil { + log.Fatalf("embedded assets: %v", err) + } + + app := application.New(application.Options{ + Name: "Oikos", + Description: "Homelab Control Room", + Services: []application.Service{ + application.NewService(&ConfigService{}), + }, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(distFS), + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + }, + }) + + systemTray := app.SystemTray.New() + systemTray.SetTooltip("Oikos") + systemTray.SetIcon(iconPNG) + + trayMenu := application.NewMenu() + trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) { + for _, w := range app.Window.GetAll() { + w.Show() + w.Focus() + } + }) + trayMenu.AddSeparator() + trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) { + go func() { + latest := fetchLatestRelease() + if latest == nil { + app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show() + return + } + for _, a := range latest.Assets { + if strings.Contains(a.Name, "darwin") { + updater.mu.Lock() + updater.latestURL = a.BrowserDownloadURL + updater.mu.Unlock() + msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version) + d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg) + yes := d.AddButton("Install") + yes.OnClick(func() { doUpdate(updater.latestURL) }) + no := d.AddButton("Later") + d.SetDefaultButton(yes) + d.SetCancelButton(no) + d.Show() + return + } + } + app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show() + }() + }) + trayMenu.AddSeparator() + trayMenu.Add("Quit").OnClick(func(ctx *application.Context) { + app.Quit() + }) + systemTray.SetMenu(trayMenu) + + ws := loadWindowState() + width, height := 1400, 900 + if ws != nil { + width = ws.Width + height = ws.Height + } + + window := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Oikos", + Width: width, + Height: height, + MinWidth: 1024, + MinHeight: 700, + URL: "/?desktop=1", + }) + + if ws != nil { + window.SetPosition(ws.X, ws.Y) + } else { + window.Center() + } + + window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + window.Hide() + e.Cancel() + }) + + window.Show() + + systemTray.AttachWindow(window) + systemTray.Run() + + app.OnShutdown(func() { + saveWindowState(window) + }) + + go pollDashboard(loadConfig()) + go checkUpdates() + + err = app.Run() + if err != nil { + log.Fatal(err) + } +} diff --git a/desktop/tray-icon.svg b/desktop/tray-icon.svg new file mode 100644 index 0000000..603e97f --- /dev/null +++ b/desktop/tray-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/desktop/wails.json b/desktop/wails.json new file mode 100644 index 0000000..1637820 --- /dev/null +++ b/desktop/wails.json @@ -0,0 +1,9 @@ +{ + "name": "oikos", + "outputfilename": "oikos-desktop", + "frontend:dir": "frontend", + "author": { + "name": "Hubris", + "email": "d.toro.v@pm.me" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d137036 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +# oikos-web deploy stack — the control-room SPA container. +# +# Publishes the same host port the oikos stack's `web` service used +# (8091:80); the outer production Caddy (caddy-conf repo, LXC 121) targets +# this published port, so no shared Docker network with the oikos stack is +# needed. Images are version-tagged by scripts/deploy.sh +# (oikos-web:v$VERSION); `latest` is only a manual-build fallback. + +services: + web: + image: oikos-web:${OIKOS_VERSION:-latest} + build: + context: . + dockerfile: compose/Dockerfile + restart: unless-stopped + ports: + - "8091:80" + stop_signal: SIGTERM + mem_limit: 64m + cpus: 0.25 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f524e58 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/dtoro/oikos-web + +go 1.26.3 + +require ( + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 + github.com/zalando/go-keyring v0.2.8 +) + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9f55237 --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..3fc2bb5 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,223 @@ +#!/bin/sh +# oikos-web deploy script — triggered by Gitea webhook on push to dtoro/oikos-web. +# Runs on mac-mini as non-root user via launchd unit oikos-web-deploy-webhook.service. +# +# Mirrors the essentials of dtoro/oikos's scripts/deploy.sh (plans D1/D2 there): +# D1 — CI gate: blocks deploy unless Gitea reports a green run for the SHA. +# D2 — versioned images: tags the image v$VERSION (from VERSION file), +# keeps the last 3 tags for rollback. +# No pg_dump / seed steps — this stack serves static files only. + +# Notify on deploy failure. Uses the Oikos API to raise an event so the +# scheduler picks it up (best-effort, silent failure). +notify_deploy_failure() { + local reason="$1" + local sha="${SHA:-unknown}" + echo "NOTIFY: deploy failed — $reason" + if [ -n "${OIKOS_API_TOKEN:-}" ]; then + curl -sf -X POST "http://localhost:8090/api/v1/events" \ + -H "Authorization: Bearer $OIKOS_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"deploy.failed\",\"severity\":\"critical\",\"source\":\"webhook\",\"data\":{\"repo\":\"oikos-web\",\"sha\":\"$sha\",\"reason\":\"$reason\"}}" \ + >/dev/null 2>&1 || true + fi +} + +set -e + +REPO_DIR="${REPO_DIR:-$PWD}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" +HEALTH_URL="${HEALTH_URL:-http://localhost:8091/}" +RETRIES=${RETRIES:-30} +SLEEP=${SLEEP:-2} + +# CI gate (D1). Set GITEA_URL + GITEA_TOKEN to enable; without them the gate +# is skipped with a warning (dev/local builds). +GITEA_URL="${GITEA_URL:-}" +GITEA_TOKEN="${GITEA_TOKEN:-}" +GITEA_OWNER="${GITEA_OWNER:-dtoro}" +GITEA_REPO="${GITEA_REPO:-oikos-web}" +CI_POLL_INTERVAL="${CI_POLL_INTERVAL:-15}" +CI_TIMEOUT="${CI_TIMEOUT:-1200}" + +# Serialize deploys (mkdir lock — atomic on POSIX, no flock on macOS). +LOCKDIR="${LOCKDIR:-/tmp/oikos-web-deploy.lock}" +if ! mkdir "$LOCKDIR" 2>/dev/null; then + oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo "") + if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then + echo "deploy already in progress (pid $oldpid) — exiting" + exit 0 + fi + echo "removing stale deploy lock (pid ${oldpid:-?} not running)" + rm -rf "$LOCKDIR" + mkdir "$LOCKDIR" +fi +echo $$ > "$LOCKDIR/pid" +trap 'rc=$?; rm -rf "$LOCKDIR" 2>/dev/null || true; if [ "$_ok" != "1" ]; then notify_deploy_failure "deploy aborted (exit $rc)"; fi' EXIT +_ok=0 + +cd "$REPO_DIR" + +echo "=== oikos-web deploy: $(date) ===" + +# Resolve the SHA we are ABOUT to deploy from the remote (read-only) so the +# CI gate can run before anything is touched. +REMOTE_FULL=$(git ls-remote origin refs/heads/main 2>/dev/null | awk '{print $1}') +if [ -z "$REMOTE_FULL" ]; then + echo "ERROR: could not resolve origin/main (offline?) — aborting before any change" + exit 1 +fi +REMOTE_SHA=$(printf '%s' "$REMOTE_FULL" | cut -c1-12) +echo "remote SHA: $REMOTE_SHA" + +# ── 1. CI gate (D1) ────────────────────────────────────────────────────── +echo "[1/6] verify CI status for $REMOTE_SHA" +verify_ci() { + sha=$1 + if [ -z "$GITEA_URL" ] || [ -z "$GITEA_TOKEN" ]; then + echo "SKIP: GITEA_URL/GITEA_TOKEN not set — CI gate disabled. Set both to enforce." + return 0 + fi + origin=$(git remote get-url origin 2>/dev/null || echo "") + seg= + case "$origin" in + *@*:*) seg=${origin##*:}; seg=${seg%.git} ;; + http://*|https://*) seg=${origin#*://}; seg=${seg#*/}; seg=${seg%.git} ;; + esac + case "$seg" in + */*) GITEA_OWNER=${seg%%/*}; GITEA_REPO=${seg#*/} ;; + esac + + api="$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/commits/$sha/status" + body=$(mktemp) + elapsed=0 + saw_ci=0 + no_signal=0 + while [ "$elapsed" -lt "$CI_TIMEOUT" ]; do + code=$(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" | \ + curl -sS -o "$body" -w '%{http_code}' --config - "$api" 2>/dev/null) || code="000" + state=$(sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$body" | head -n1) + + case "$code" in + 200) + case "$state" in + success) + rm -f "$body" + echo "CI: green for $sha after ${elapsed}s" + return 0 + ;; + failure|error) + rm -f "$body" + echo "ERROR: CI $state for $sha — refusing to deploy." + echo " See $GITEA_URL/$GITEA_OWNER/$GITEA_REPO/actions" + return 1 + ;; + pending|"") + saw_ci=1 + no_signal=0 + ;; + esac + ;; + 404) + rm -f "$body" + echo "WARN: Gitea has no CI status for $sha (404)." + echo " Is Gitea Actions configured with a runner for $GITEA_OWNER/$GITEA_REPO?" + echo " Proceeding without a gate." + return 0 + ;; + 401|403) + rm -f "$body" + echo "WARN: GITEA_TOKEN rejected by Gitea ($code) — cannot verify CI." + echo " Fix the token to enforce the gate; proceeding without one." + return 0 + ;; + *) + no_signal=$((no_signal + 1)) + ;; + esac + + if [ "$saw_ci" -eq 0 ] && [ "$no_signal" -ge 4 ]; then + rm -f "$body" + echo "WARN: no CI signal from Gitea after ${elapsed}s (last code=$code)." + echo " CI may be down or misconfigured; proceeding without a gate." + return 0 + fi + + sleep "$CI_POLL_INTERVAL" + elapsed=$((elapsed + CI_POLL_INTERVAL)) + printf '\rCI: waiting (%ss, code=%s state=%s)...' "$elapsed" "$code" "${state:-none}" + done + rm -f "$body" + echo "" + echo "ERROR: CI did not reach a terminal state within ${CI_TIMEOUT}s for $sha — refusing to deploy." + return 1 +} +verify_ci "$REMOTE_SHA" || exit 1 + +# ── 2. Update working tree to the verified commit ──────────────────────── +echo "[2/6] git pull (ff-only)" +git pull --ff-only origin main +# TOCTOU guard: origin/main may have advanced during the CI wait; refuse to +# ship an unverified commit — a retry verifies the new tip. +PULLED_FULL=$(git rev-parse HEAD) +if [ "$PULLED_FULL" != "$REMOTE_FULL" ]; then + echo "ERROR: origin/main advanced during deploy (verified $REMOTE_SHA, now at $(git rev-parse --short HEAD)) — aborting; retry verifies the new tip" + exit 1 +fi +SHA=$(git rev-parse --short HEAD) +echo "SHA (deployed): $SHA" + +# Resolve the deploy version AFTER pull (D2) so the tag matches the code +# being built. Compose interpolates $OIKOS_VERSION into the image: tag. +VERSION_FILE="$REPO_DIR/VERSION" +if [ -f "$VERSION_FILE" ]; then + OIKOS_VERSION="v$(head -n1 "$VERSION_FILE" | tr -d '[:space:]')" + export OIKOS_VERSION + echo "VERSION: $OIKOS_VERSION" +else + echo "WARNING: VERSION file missing — image will use :latest (rollback unavailable)" +fi + +# ── 3. Build version-tagged image (D2) ─────────────────────────────────── +echo "[3/6] docker compose build" +DOCKER_BUILDKIT=1 docker compose -f "$COMPOSE_FILE" build \ + --build-arg BUILDKIT_INLINE_CACHE=1 + +# ── 4. Rolling restart ─────────────────────────────────────────────────── +echo "[4/6] docker compose up -d" +docker compose -f "$COMPOSE_FILE" up -d --remove-orphans + +# ── 5. Prune old image tags — keep the 3 newest so rollback ────────────── +# (OIKOS_VERSION=v0.x.y docker compose up) stays available. +echo "[5/6] prune old image tags (keep 3)" +if [ -n "$OIKOS_VERSION" ]; then + images=$(docker compose -f "$COMPOSE_FILE" config --images 2>/dev/null || true) + if [ -z "$images" ]; then + images="oikos-web" + fi + printf '%s\n' $images | sed 's/:.*//' | sort -u | while read -r repo; do + docker image ls "$repo" --format '{{.Tag}}' 2>/dev/null | grep '^v' | sort -rV | tail -n +4 | while read -r tag; do + docker rmi "$repo:$tag" >/dev/null 2>&1 || true + done + done +fi + +# ── 6. Health check wait (SPA answers on the published port) ───────────── +echo "[6/6] health check" +healthy=0 +for i in $(seq 1 $RETRIES); do + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + echo "healthy after ${i}x${SLEEP}s" + healthy=1 + break + fi + sleep "$SLEEP" +done +if [ "$healthy" -ne 1 ]; then + echo "ERROR: health check failed after $((RETRIES * SLEEP))s" + exit 1 +fi + +# All steps completed successfully — clear failure trap +_ok=1 +exit 0 diff --git a/scripts/install-webhook.sh b/scripts/install-webhook.sh new file mode 100755 index 0000000..2770045 --- /dev/null +++ b/scripts/install-webhook.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Install the oikos-web deploy-webhook launchd unit on the mac-mini. +# +# Renders scripts/oikos-web-deploy-webhook.plist with real secret values and +# loads it. Values come from env; when absent, the HMAC secret and API token +# are resolved from the oikos stack's Infisical via the oikos CLI (only +# available on the mac-mini with the oikos checkout + .env). +# +# Usage: +# WEBHOOK_HMAC_SECRET=... GITEA_TOKEN=... ./scripts/install-webhook.sh +set -e + +REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +PLIST_DST="$HOME/Library/LaunchAgents/network.hubris.oikos-web-deploy-webhook.plist" +LABEL="network.hubris.oikos-web-deploy-webhook" + +OIKOS_CLI="${OIKOS_CLI:-$HOME/Projects/oikos/oikos}" +infisical_get() { + [ -x "$OIKOS_CLI" ] || return 1 + (cd "$HOME/Projects/oikos" && set -a && . ./.env 2>/dev/null && set +a \ + && OIKOS_INFISICAL_SITE_URL=http://localhost:8080 "$OIKOS_CLI" secret get "$1" 2>/dev/null) \ + | grep -E '^[0-9a-f]{40,}$' | head -n1 +} + +HMAC="${WEBHOOK_HMAC_SECRET:-$(infisical_get webhook_hmac-secret || true)}" +GITEA_TOKEN="${GITEA_TOKEN:-$(infisical_get gitea-pat_token || true)}" +API_TOKEN="${OIKOS_API_TOKEN:-$(infisical_get api_token || true)}" + +if [ -z "$HMAC" ]; then + echo "ERROR: WEBHOOK_HMAC_SECRET not set and could not resolve from Infisical" >&2 + exit 1 +fi + +# Build the webhook binary first +(cd "$REPO_DIR" && make webhook) + +launchctl bootout "gui/$(id -u)" "$PLIST_DST" 2>/dev/null || true + +cat > "$PLIST_DST" < + + + + Label + $LABEL + ProgramArguments + + $REPO_DIR/webhook + + WorkingDirectory + $REPO_DIR + EnvironmentVariables + + HOME + $HOME + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + WEBHOOK_LISTEN + :9798 + WEBHOOK_REPO_DIR + $REPO_DIR + WEBHOOK_HMAC_SECRET + $HMAC + GITEA_URL + https://git.hubris.network + GITEA_TOKEN + $GITEA_TOKEN + OIKOS_API_TOKEN + $API_TOKEN + + RunAtLoad + + KeepAlive + + StandardOutPath + $HOME/Library/Logs/oikos-web-webhook.log + StandardErrorPath + $HOME/Library/Logs/oikos-web-webhook.log + + +EOF +chmod 600 "$PLIST_DST" + +launchctl bootstrap "gui/$(id -u)" "$PLIST_DST" +sleep 1 +launchctl print "gui/$(id -u)/$LABEL" >/dev/null && echo "installed: $LABEL (listening :9798)" diff --git a/scripts/oikos-web-deploy-webhook.plist b/scripts/oikos-web-deploy-webhook.plist new file mode 100644 index 0000000..d2cac04 --- /dev/null +++ b/scripts/oikos-web-deploy-webhook.plist @@ -0,0 +1,38 @@ + + + + + Label + oikos-web-deploy-webhook + ProgramArguments + + /Users/dtoro/Projects/oikos-web/webhook + + EnvironmentVariables + + WEBHOOK_LISTEN + :9798 + WEBHOOK_REPO_DIR + /Users/dtoro/Projects/oikos-web + + WEBHOOK_HMAC_SECRET + SET_AT_INSTALL + GITEA_URL + https://git.hubris.network + GITEA_TOKEN + SET_AT_INSTALL + OIKOS_API_TOKEN + SET_AT_INSTALL + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/oikos-web-deploy-webhook.log + StandardErrorPath + /tmp/oikos-web-deploy-webhook.log + + diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..386fd18 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +build/ +package-lock.json diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..9372aab --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "useTabs": false, + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..a2b1686 --- /dev/null +++ b/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "vega", + "tailwind": { + "css": "src/app.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..0929bf6 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,39 @@ +import js from '@eslint/js' +import ts from 'typescript-eslint' +import svelte from 'eslint-plugin-svelte' +import globals from 'globals' + +export default ts.config( + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs['flat/recommended'], + { + files: ['**/*.{ts,js,svelte}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + } + }, + { + files: ['**/*.svelte'], + languageOptions: { + parserOptions: { + parser: ts.parser + } + } + }, + { + ignores: ['dist/', 'node_modules/', 'build/', '*.config.{ts,js}'] + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } + ] + } + } +) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..578fefc --- /dev/null +++ b/web/index.html @@ -0,0 +1,31 @@ + + + + + + Oikos + + + + + + +
+ + + + + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..8e373bf --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,6425 @@ +{ + "name": "oikos-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "oikos-web", + "version": "0.1.0", + "dependencies": { + "@joan/procedural-glyph-engine": "file:../vendor", + "@sigma/node-image": "^3.0.0", + "@surdeddd/wmkit": "^0.3.0", + "clsx": "^2.1.1", + "d3-force": "^3.0.0", + "dompurify": "^3.4.11", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "marked": "^18.0.5", + "sigma": "^3.0.3", + "svelte-splitpanes": "^8.0.12", + "tailwind-merge": "^3.6.0", + "uplot": "^1.6.32" + }, + "devDependencies": { + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.25.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.3.2", + "@tsconfig/svelte": "^5.0.0", + "@types/d3-force": "^3.0.10", + "@vincjo/datatables": "^2.8.1", + "bits-ui": "^2.18.1", + "eslint": "^9.0.0", + "eslint-plugin-svelte": "^2.46.0", + "globals": "^15.0.0", + "jsdom": "^25.0.0", + "prettier": "^3.3.0", + "prettier-plugin-svelte": "^3.3.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "svelte-sonner": "^1.1.1", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.3.2", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.0", + "vite": "^6.0.0", + "vitest": "^2.0.0" + } + }, + "../vendor": { + "name": "@joan/procedural-glyph-engine", + "version": "0.1.0" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", + "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@joan/procedural-glyph-engine": { + "resolved": "../vendor", + "link": true + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.28.0.tgz", + "integrity": "sha512-C1Ge84KW2z4q44/WDIEo/2KC2jby5u6mSlta7q+p6g7u0ld8sC/w1fMBiSswF+4bxAl9jlzzaVTpp79qqkiy5g==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sigma/node-image": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sigma/node-image/-/node-image-3.0.0.tgz", + "integrity": "sha512-i4WLNPugDY4jgQEZtNSiSVj4HHXOraciXLtlgdygeUxMVEhH8PJ/+Q1vQ9f/SlKFnZQ+7vH3HnsSDW6FD9aP+g==", + "license": "MIT", + "peerDependencies": { + "sigma": ">=3.0.0-beta.10" + } + }, + "node_modules/@surdeddd/wmkit": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@surdeddd/wmkit/-/wmkit-0.3.0.tgz", + "integrity": "sha512-r5reUXN0Mcnx1nFjfB2grPCqpkH67S7UdaIY1bhyls5Tim4RyAAVscQSsimo1fOSqndZs9venjh3bueLJQVEMA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@angular/core": ">=16", + "react": ">=18", + "solid-js": ">=1.8", + "svelte": ">=4", + "vue": ">=3.3" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "react": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz", + "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "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" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vincjo/datatables": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@vincjo/datatables/-/datatables-2.8.1.tgz", + "integrity": "sha512-rWl17XkriNyX3fFB5GSThLlhlPDKchFMMSCuaeSYbZCokkwSACjTLtk9v3gg4PltUaXMsJ2XjQcpnPeKJ0xa5A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^5.56.1" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "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, + "license": "MIT" + }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "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, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz", + "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@jridgewell/sourcemap-codec": "^1.4.15", + "eslint-compat-utils": "^0.5.1", + "esutils": "^2.0.3", + "known-css-properties": "^0.35.0", + "postcss": "^8.4.38", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.1.0", + "semver": "^7.6.2", + "svelte-eslint-parser": "^0.43.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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, + "license": "ISC" + }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-layout-forceatlas2": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", + "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.1.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz", + "integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", + "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz", + "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sigma": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz", + "integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "graphology-utils": "^2.5.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.4.tgz", + "integrity": "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", + "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "postcss": "^8.4.39", + "postcss-scss": "^4.0.9" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-sonner": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.1.tgz", + "integrity": "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/svelte-splitpanes": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/svelte-splitpanes/-/svelte-splitpanes-8.0.12.tgz", + "integrity": "sha512-HJ07HgbtY0Q/35TEuJquGy47dtgCVavV7ay9r1FhWRx3boyUs3RpeiHlwMKliznCKy2ZotNeT+8GG+mIoNjgRA==", + "license": "MIT", + "peerDependencies": { + "svelte": "^5.43.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz", + "integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=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 + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..709ead1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,59 @@ +{ + "name": "oikos-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.25.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.3.2", + "@tsconfig/svelte": "^5.0.0", + "@types/d3-force": "^3.0.10", + "@vincjo/datatables": "^2.8.1", + "bits-ui": "^2.18.1", + "eslint": "^9.0.0", + "eslint-plugin-svelte": "^2.46.0", + "globals": "^15.0.0", + "jsdom": "^25.0.0", + "prettier": "^3.3.0", + "prettier-plugin-svelte": "^3.3.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "svelte-sonner": "^1.1.1", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.3.2", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.0", + "vite": "^6.0.0", + "vitest": "^2.0.0" + }, + "dependencies": { + "@joan/procedural-glyph-engine": "file:../vendor", + "@sigma/node-image": "^3.0.0", + "@surdeddd/wmkit": "^0.3.0", + "clsx": "^2.1.1", + "d3-force": "^3.0.0", + "dompurify": "^3.4.11", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "marked": "^18.0.5", + "sigma": "^3.0.3", + "svelte-splitpanes": "^8.0.12", + "tailwind-merge": "^3.6.0", + "uplot": "^1.6.32" + } +} diff --git a/web/public/android-chrome-192.png b/web/public/android-chrome-192.png new file mode 100644 index 0000000..f00b11f Binary files /dev/null and b/web/public/android-chrome-192.png differ diff --git a/web/public/android-chrome-512.png b/web/public/android-chrome-512.png new file mode 100644 index 0000000..f00b11f Binary files /dev/null and b/web/public/android-chrome-512.png differ diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..f00b11f Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 0000000..f00b11f Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..edac491 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/public/fonts/JetBrainsMono-Bold.woff2 b/web/public/fonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 0000000..3a4e333 Binary files /dev/null and b/web/public/fonts/JetBrainsMono-Bold.woff2 differ diff --git a/web/public/fonts/JetBrainsMono-Regular.woff2 b/web/public/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000..5858873 Binary files /dev/null and b/web/public/fonts/JetBrainsMono-Regular.woff2 differ diff --git a/web/public/fonts/VT323-Regular.woff2 b/web/public/fonts/VT323-Regular.woff2 new file mode 100644 index 0000000..fd760b5 Binary files /dev/null and b/web/public/fonts/VT323-Regular.woff2 differ diff --git a/web/public/mascot/LICENSE-eggs.txt b/web/public/mascot/LICENSE-eggs.txt new file mode 100644 index 0000000..2325b0d --- /dev/null +++ b/web/public/mascot/LICENSE-eggs.txt @@ -0,0 +1,20 @@ +Eggs +By Onocentaur +https://onocentaur.itch.io +March 2021 + +Description +350+ pixel art eggs for your next virtual pet/match 3/farming/holiday themed game. + +This pack contains: + ⁃ Over 350 16x16px eggs. + ⁃ 32 egg designs, 11 color variants. + ⁃ 2 different cracking animations for each egg. + ⁃ Spritesheets for each color variant and cracking pattern. + ⁃ Transparent PNGs. + ⁃ Template files so you can color your own eggs. + ⁃ Bonus: Letter & Number eggs. + ⁃ Bonus: Incubator assets (nesting box and toggle-able lamp). + ⁃ Bonus: 12 Animal assets. + +Free to use for personal & professional projects. Attribution appreciated. If you use these assets in your project, let me know! I look forward to seeing what you make. \ No newline at end of file diff --git a/web/public/mascot/LICENSE.txt b/web/public/mascot/LICENSE.txt new file mode 100644 index 0000000..c7fcea0 --- /dev/null +++ b/web/public/mascot/LICENSE.txt @@ -0,0 +1,5 @@ + +CC0 1.0 Universal (CC0 1.0) Public Domain Dedication + +The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law. +You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission. \ No newline at end of file diff --git a/web/public/mascot/blink.png b/web/public/mascot/blink.png new file mode 100644 index 0000000..ce49a94 Binary files /dev/null and b/web/public/mascot/blink.png differ diff --git a/web/public/mascot/egg-crack.png b/web/public/mascot/egg-crack.png new file mode 100644 index 0000000..14d8496 Binary files /dev/null and b/web/public/mascot/egg-crack.png differ diff --git a/web/public/mascot/egg-idle.png b/web/public/mascot/egg-idle.png new file mode 100644 index 0000000..0fc40a9 Binary files /dev/null and b/web/public/mascot/egg-idle.png differ diff --git a/web/public/mascot/egg-shell.png b/web/public/mascot/egg-shell.png new file mode 100644 index 0000000..2280e55 Binary files /dev/null and b/web/public/mascot/egg-shell.png differ diff --git a/web/public/mascot/hurt.png b/web/public/mascot/hurt.png new file mode 100644 index 0000000..8aeb70e Binary files /dev/null and b/web/public/mascot/hurt.png differ diff --git a/web/public/mascot/idle.png b/web/public/mascot/idle.png new file mode 100644 index 0000000..492214f Binary files /dev/null and b/web/public/mascot/idle.png differ diff --git a/web/public/mascot/jump.png b/web/public/mascot/jump.png new file mode 100644 index 0000000..4262c1f Binary files /dev/null and b/web/public/mascot/jump.png differ diff --git a/web/public/mascot/peck.png b/web/public/mascot/peck.png new file mode 100644 index 0000000..5c575f4 Binary files /dev/null and b/web/public/mascot/peck.png differ diff --git a/web/public/mascot/peep.png b/web/public/mascot/peep.png new file mode 100644 index 0000000..86f3e72 Binary files /dev/null and b/web/public/mascot/peep.png differ diff --git a/web/public/mascot/react-displeased.png b/web/public/mascot/react-displeased.png new file mode 100644 index 0000000..6edd56b Binary files /dev/null and b/web/public/mascot/react-displeased.png differ diff --git a/web/public/mascot/react-joy.png b/web/public/mascot/react-joy.png new file mode 100644 index 0000000..c5c86d2 Binary files /dev/null and b/web/public/mascot/react-joy.png differ diff --git a/web/public/mascot/react-sigh.png b/web/public/mascot/react-sigh.png new file mode 100644 index 0000000..20376a8 Binary files /dev/null and b/web/public/mascot/react-sigh.png differ diff --git a/web/public/mascot/react-surprise.png b/web/public/mascot/react-surprise.png new file mode 100644 index 0000000..7228107 Binary files /dev/null and b/web/public/mascot/react-surprise.png differ diff --git a/web/public/mascot/react-yell.png b/web/public/mascot/react-yell.png new file mode 100644 index 0000000..477cc35 Binary files /dev/null and b/web/public/mascot/react-yell.png differ diff --git a/web/public/mascot/sleep.png b/web/public/mascot/sleep.png new file mode 100644 index 0000000..4d96ae5 Binary files /dev/null and b/web/public/mascot/sleep.png differ diff --git a/web/public/mascot/walk.png b/web/public/mascot/walk.png new file mode 100644 index 0000000..3ebb4d4 Binary files /dev/null and b/web/public/mascot/walk.png differ diff --git a/web/public/mascot/walk2.png b/web/public/mascot/walk2.png new file mode 100644 index 0000000..fbf7987 Binary files /dev/null and b/web/public/mascot/walk2.png differ diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..304bd6e --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,66 @@ + + +{#if !configured} + (configured = true)} + onCancel={isConfigured() ? () => (configured = true) : undefined} + /> +{:else} + + +{/if} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..00d8b23 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,626 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:is(.dark *)); + +/* ── Self-hosted type (cyberspace terminal aesthetic) ── */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2'); +} +@font-face { + font-family: 'VT323'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/VT323-Regular.woff2') format('woff2'); +} + +/* bits-ui components (Slider, and any future orientation/disabled-aware + primitive) style themselves via shorthand data-* variants that Tailwind + v4 doesn't ship — it only auto-generates variants for bare boolean data + attributes (data-disabled), not attribute=value pairs like + data-orientation="horizontal". Without these, e.g. Slider's track silently + collapses to 0 height (no h-1.5 class survives), leaving only the thumb + visible with no visible rail. */ +@custom-variant data-horizontal (&[data-orientation='horizontal']); +@custom-variant data-vertical (&[data-orientation='vertical']); +@custom-variant data-disabled (&[data-disabled]); + +@theme inline { + --font-sans: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --font-heading: 'JetBrains Mono', ui-monospace, Menlo, monospace; + /* Square corners across the whole radius scale (--radius is pinned to 0 + by both themes below). Kept as a 4-step scale so any future softer theme + can relax just --radius and get graded corners back for free. */ + --radius-sm: var(--radius); + --radius-md: var(--radius); + --radius-lg: var(--radius); + --radius-xl: var(--radius); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-warning: var(--warning); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); +} + +/* ── Cyberspace Light (black ink on warm cream paper) ── + Ported from cyberspace.online's 3-color model (fg/bg/fgDim). Light and + dark are exact inverses of the same cream (#efe5c0). Emphasis is by + inversion (primary = fg ink), borders are fg-derived hairlines, and the + radius is 0 so every surface is square. */ +:root { + --radius: 0px; + --background: #efe5c0; + --foreground: #000000; + --card: #efe5c0; + --card-foreground: #000000; + --popover: #efe5c0; + --popover-foreground: #000000; + --primary: #000000; + --primary-foreground: #efe5c0; + --secondary: #e0d6b0; + --secondary-foreground: #000000; + --muted: #e6dcc0; + --muted-foreground: #3a3a3a; + --accent: #000000; + --accent-foreground: #efe5c0; + --destructive: #9d0006; + --destructive-foreground: #efe5c0; + --border: color-mix(in oklab, #000000 22%, transparent); + --input: color-mix(in oklab, #000000 30%, transparent); + --ring: #000000; + --chart-1: #000000; + --chart-2: #3a3a3a; + --chart-3: #b57614; + --chart-4: #79740e; + --chart-5: #076678; + --sidebar: #efe5c0; + --sidebar-foreground: #000000; + --sidebar-primary: #000000; + --sidebar-primary-foreground: #efe5c0; + --sidebar-accent: #e0d6b0; + --sidebar-accent-foreground: #000000; + --sidebar-border: color-mix(in oklab, #000000 22%, transparent); + --sidebar-ring: #000000; + --success: #79740e; + --warning: #b57614; + + --bg: var(--background); + --bg-surface: var(--card); + --bg-deeper: #e6dcc0; + --bg-hover: var(--secondary); + --bg-active: var(--accent); + --text: var(--foreground); + --text-muted: var(--muted-foreground); + --accent-blue: #076678; + --accent-green: var(--success); + --accent-red: var(--destructive); + --accent-orange: var(--warning); +} + +/* ── Cyberspace Dark (warm cream on black) — exact inverse of Light ── */ +.dark { + --radius: 0px; + --background: #000000; + --foreground: #efe5c0; + --card: #000000; + --card-foreground: #efe5c0; + --popover: #000000; + --popover-foreground: #efe5c0; + --primary: #efe5c0; + --primary-foreground: #000000; + --secondary: #1a1a1a; + --secondary-foreground: #efe5c0; + --muted: #141414; + --muted-foreground: #a89984; + --accent: #efe5c0; + --accent-foreground: #000000; + --destructive: #cc241d; + --destructive-foreground: #efe5c0; + --border: color-mix(in oklab, #efe5c0 22%, transparent); + --input: color-mix(in oklab, #efe5c0 30%, transparent); + --ring: #efe5c0; + --chart-1: #efe5c0; + --chart-2: #a89984; + --chart-3: #fabd2f; + --chart-4: #b8bb26; + --chart-5: #83a598; + --sidebar: #000000; + --sidebar-foreground: #efe5c0; + --sidebar-primary: #efe5c0; + --sidebar-primary-foreground: #000000; + --sidebar-accent: #1a1a1a; + --sidebar-accent-foreground: #efe5c0; + --sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent); + --sidebar-ring: #efe5c0; + --success: #b8bb26; + --warning: #fabd2f; + + --bg: var(--background); + --bg-surface: var(--card); + --bg-deeper: #050505; + --bg-hover: var(--secondary); + --bg-active: var(--accent); + --text: var(--foreground); + --text-muted: var(--muted-foreground); + --accent-blue: #83a598; + --accent-green: var(--success); + --accent-red: var(--destructive); + --accent-orange: var(--warning); +} + +/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */ +.cm-cursor, +.cm-cursor-primary { + border-left-color: var(--primary) !important; + border-left-width: 0.5em !important; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html, + body { + @apply bg-background text-foreground; + height: 100%; + font-size: 15px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: var(--font-heading); + } + + ::selection { + background: var(--primary); + color: var(--primary-foreground); + } + + .cm-content { + background: var(--background); + } + + /* Theme-aware scrollbars */ + * { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; + } + + *::-webkit-scrollbar-thumb:hover { + background: var(--primary); + } + + *::-webkit-scrollbar-corner { + background: transparent; + } + + /* Pointer cursor on all interactive elements */ + button:not(:disabled), + [role='button']:not([aria-disabled='true']), + a[href], + summary, + select { + cursor: pointer; + } + + a, + [role='link'], + [role='tab'], + [role='option'], + [data-slot='popover-trigger'], + [data-slot='toggle-group-item'], + [data-slot='alert-dialog-action'], + [data-slot='alert-dialog-cancel'], + .cm-tooltip-autocomplete [role='option'] { + cursor: pointer; + } + + [data-slot='table-container'] { + border: none; + box-shadow: none; + } +} + +#app { + height: 100%; +} + +/* wmkit floating windows (EntityDesktop.svelte) — mapped onto the app's own + card/border/ring tokens instead of an imported wmkit theme, so windows + follow the terracotta/dark theme toggle for free. */ +[data-wm-desktop] { + overflow: visible; +} + +[data-wm-window] { + display: flex; + flex-direction: column; + box-sizing: border-box; + pointer-events: auto; + background: var(--card); + color: var(--card-foreground); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + /* Hard offset shadow (DOS-style), not a soft drop shadow — keeps the + border-driven system and still separates stacked windows, which matters + because --card now equals the desktop background. */ + box-shadow: 3px 3px 0 0 var(--border); + overflow: hidden; + outline: none; +} + +[data-wm-window][data-wm-focused] { + border-color: var(--ring); + box-shadow: 3px 3px 0 0 var(--ring); +} + +[data-wm-window][data-wm-dragging], +[data-wm-window][data-wm-resizing] { + user-select: none; +} + +[data-wm-window][data-wm-stage='minimized'] { + display: none; +} + +[data-wm-resize] { + position: absolute; +} + +a { + color: var(--accent-blue); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* svelte-splitpanes theming (TaskContextPanel, SessionChatWindow rail) — + mapped onto the app's border/primary tokens instead of the library's + default-theme, so splitters follow the terracotta/dark theme toggle. */ +.splitpanes.oikos-theme .splitpanes__pane { + background: transparent; +} + +.splitpanes.oikos-theme .splitpanes__splitter { + background-color: transparent; + transition: background-color 0.15s; +} + +.splitpanes.oikos-theme .splitpanes__splitter:hover, +.splitpanes.oikos-theme .splitpanes__splitter.splitpanes__splitter__active { + background-color: color-mix(in oklab, var(--primary) 30%, transparent); +} + +.oikos-theme.splitpanes--horizontal > .splitpanes__splitter { + height: 6px; + border-bottom: 1px solid var(--border); + cursor: row-resize; +} + +.oikos-theme.splitpanes--vertical > .splitpanes__splitter { + width: 6px; + border-left: 1px solid var(--border); + cursor: col-resize; +} + +/* Base markdown rendering — used by every {@html marked.parse(...)} output + (EntityDetailContent, the Knowledge wiki's WikiReader, and as the + foundation ChatThread's fuller "Art Nouveau" chat styling builds on top + of). Global rather than a per-component diff --git a/web/src/lib/components/ConfigBackground.svelte b/web/src/lib/components/ConfigBackground.svelte new file mode 100644 index 0000000..6835210 --- /dev/null +++ b/web/src/lib/components/ConfigBackground.svelte @@ -0,0 +1,170 @@ + + +
+ +
diff --git a/web/src/lib/components/DetailSection.svelte b/web/src/lib/components/DetailSection.svelte new file mode 100644 index 0000000..fc2b3e8 --- /dev/null +++ b/web/src/lib/components/DetailSection.svelte @@ -0,0 +1,43 @@ + + + + + {title}{count !== undefined ? ` (${count})` : ''} + + +
+ {@render children()} +
+
+
diff --git a/web/src/lib/components/EmptyState.svelte b/web/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..91d8bbc --- /dev/null +++ b/web/src/lib/components/EmptyState.svelte @@ -0,0 +1,20 @@ + + + + + {message} + + diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte new file mode 100644 index 0000000..ffefa6c --- /dev/null +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -0,0 +1,1188 @@ + + +
+ {#if loading} + + + + + {:else if !entity} +

Entity "{slug}" not found.

+ {:else} + +
+
+
+

{entity.slug}

+
+ {entity.type} + {#if entity.state && entity.state !== 'active'} + ·{entity.state} + {/if} +
+
+ +
+ + {#if isObservable(entity.type)} +
+
+ + {verdict.health} + {#if entity.last_check_at} + · checked {relativeTime(entity.last_check_at)} + {/if} +
+ + {#if verdict.reason} +

{verdict.reason}

+ {/if} + {#if openSignals.length > 0} +

+ {openSignals.length} + open signal{openSignals.length === 1 ? '' : 's'} +

+ {/if} +
+ {/if} +
+ + {#snippet detailsContent()} +
+
+
Type
+
{entity.type}
+
+
+
State
+
+ {#if entity.state}{entity.state}{:else}{/if} +
+
+
+
Health
+
+ {#if entity.health} + + + {entity.health} · checked {relativeTime(entity.last_check_at)} + + {:else} + not monitored + {/if} +
+
+
+
Version
+
{entity.version}
+
+
+
Created
+
{relativeTime(entity.created_at)}
+
+
+
Updated
+
{relativeTime(entity.updated_at)}
+
+ {#if entity.maintenance_until} +
+
Maintenance until
+
{new Date(entity.maintenance_until).toLocaleString()}
+
+ {/if} +
+ {/snippet} + + {#snippet monitoringContent()} +
+ {#each checks as check (check.id)} +
+
+ {check.kind} + every {check.interval_s}s +
+ +
+ {:else} +

No checks configured for this entity.

+ {/each} +
+ {/snippet} + + {#snippet contentContent()} + {#if ownContent} +
+ + {@html renderMarkdown(ownContent.content)} +
+ {:else} +

No content.

+ {/if} + {/snippet} + + {#snippet attributesContent()} + {#if entity.attributes && Object.keys(entity.attributes).length} + {@const rows = classifyAttributes(entity.attributes)} +
+ {#each rows as row (row.key)} + {#if row.kind === 'long-text'} +
+

{row.key}

+

+ {row.value} +

+
+ {:else if row.kind === 'changelog'} +
+

{row.key} ({row.value.length})

+
+ {#each row.value as entry} +
+
+ {#if entry.date}{entry.date}{/if} + {#if entry.title}{entry.title}{/if} +
+ {#if entry.body}

+ {entry.body} +

{/if} +
+ {/each} +
+
+ {:else if row.kind === 'flat-object'} +
+

{row.key}

+
+ {#each Object.entries(row.value) as [subKey, subValue]} +
+
{subKey}
+
{String(subValue)}
+
+ {/each} +
+
+ {:else} +
+
{row.key}
+
+ {#if row.value !== null && typeof row.value === 'object'} +
{JSON.stringify(
+                        row.value,
+                        null,
+                        2
+                      )}
+ {:else} + {String(row.value)} + {/if} +
+
+ {/if} + {/each} +
+ {:else} +

No attributes.

+ {/if} + {/snippet} + + {#snippet relationRow(rel: Relationship)} +
+ {#if onSelectEntity} + + —{rel.type}→ + + {:else} + {truncateMiddle(rel.source)} + —{rel.type}→ + {truncateMiddle(rel.target)} + {/if} +
+ {/snippet} + + {#snippet relationsContent()} + {#if outgoingRelations.length === 0 && incomingRelations.length === 0} +

No direct relations.

+ {:else} +
+ {#if outgoingRelations.length} +
+
+ Outgoing ({outgoingRelations.length}) +
+
+ {#each showAllRelations ? outgoingRelations : outgoingRelations.slice(0, RELATION_PREVIEW) as rel} + {@render relationRow(rel)} + {/each} +
+
+ {/if} + {#if incomingRelations.length} +
+
+ Incoming ({incomingRelations.length}) +
+
+ {#each showAllRelations ? incomingRelations : incomingRelations.slice(0, RELATION_PREVIEW) as rel} + {@render relationRow(rel)} + {/each} +
+
+ {/if} + + {#if !showAllRelations && hiddenRelations > 0} + + {/if} +
+ {/if} + {/snippet} + + {#snippet metricsContent()} + {#if metrics.length} +
+ {#each metrics as series (series.metric)} +
+

{series.metric} ({series.rollup})

+
+
+ {/each} +
+ {:else} +

No metrics tracked.

+ {/if} + {/snippet} + + {#snippet signalsContent()} +
+ {#each signals as signal (signal.id)} +
+
+ {signal.kind} +
+ {signal.severity} + {signal.state} +
+
+ {#if ['raised', 'acknowledged', 'acting'].includes(signal.state)} +
+ {#if signal.state === 'raised'} + + {/if} + + +
+ {/if} +
+ {:else} +

None.

+ {/each} +
+ {/snippet} + + {#snippet executionsContent()} +
+ {#each executions as execution (execution.id)} + {@const output = executionOutput(execution)} + {@const running = isRunning(execution)} + {@const expanded = expandedExecution === execution.id} +
+
+ +
+ {#if execution.duration_ms != null} + {formatDuration(execution.duration_ms)} + {:else if running && execution.started_at} + + {relativeTime(execution.started_at)} + {/if} + {execution.status} +
+
+
+ {relativeTime(execution.started_at ?? execution.created_at)} + · + {execution.risk_class} +
+ {#if expanded} + {@const shown = running ? streamedOutput : streamedOutput || output} + {#if shown} +
{shown}
+ {:else if running} +

Waiting for output…

+ {/if} + {#if running} +
+ + {followTail ? 'following output' : 'scrolled up — paused'} +
+ {/if} + {/if} +
+ {:else} +

None.

+ {/each} +
+ {/snippet} + + {#snippet tasksContent()} +
+ {#each tasks as { task, executionCount } (task.id)} + {@const title = + typeof task.attributes?.title === 'string' ? task.attributes.title : task.name} + {@const outcome = + typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined} +
+ {#if onSelectEntity} + + {:else} + {title} + {/if} +
+ {#if outcome} + {outcome} + {/if} + {executionCount} action{executionCount === 1 ? '' : 's'} +
+
+ {:else} +

No tasks have acted on this entity.

+ {/each} +
+ {/snippet} + + {#snippet knowledgeContent()} +
+ {#each knowledge as hit (hit.id)} +
+ {hit.type}{hit.title} +
+ {:else} +

None linked.

+ {/each} +
+ {/snippet} + + {#snippet eventsContent()} +
+ {#each events as ev (ev.id)} +
+ {new Date(ev.ts).toLocaleString()} + {ev.type} +
+ {:else} +

No events yet.

+ {/each} +
+ {/snippet} + + {#snippet agentActivityContent()} +
+ {#each agentActivity as activity (activity.id)} +
+
+ {new Date(activity.ts).toLocaleString()} + {activity.activity_type} +
+ {activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''} +
+ {:else} +

No agent activity.

+ {/each} +
+ {/snippet} + + {#snippet auditContent()} +
+ {#each auditEntries as entry (entry.id)} +
+
+ {new Date(entry.ts).toLocaleString()} + {entry.actor_type} +
+ {entry.actor_id ?? '—'} · {entry.action} +
+ {:else} +

No audit entries.

+ {/each} +
+ {/snippet} + + + {#snippet statusContent()} +
+ {#each checks.filter((c) => c.enabled) as check (check.id)} +
+
+ + {checkLabel(check)} +
+
+ + {check.last_run_at ? relativeTime(check.last_run_at) : 'not yet run'} + + +
+
+ {:else} +

+ No checks configured. This entity is unmonitored — its health cannot be known. +

+ {/each} + {#if checks.some((c) => !c.enabled)} +

+ {checks.filter((c) => !c.enabled).length} disabled +

+ {/if} +
+ {/snippet} + + + {#snippet impactContent()} +
+ {#if blast.length > 1} +
+ {#each blast.filter((b) => b.depth > 0).slice(0, 12) as b (b.entity.id)} + + {/each} +
+ +

+ Reachable by following this entity's own dependencies. Does not yet include things that + point at it. +

+ {:else} +

Nothing downstream depends on this.

+ {/if} +
+ {/snippet} + + + {#snippet activityContent()} +
+ {#if openSignals.length > 0} +
+

+ Open signals +

+ {@render signalsContent()} +
+ {/if} + {#if executions.length > 0} +
+

+ Executions +

+ {@render executionsContent()} +
+ {/if} + {#if tasks.length > 0} +
+

+ Tasks +

+ {@render tasksContent()} +
+ {/if} + {#if events.length > 0} +
+

+ Events +

+ {@render eventsContent()} +
+ {/if} + {#if openSignals.length + executions.length + tasks.length + events.length === 0} +

Nothing has happened here yet.

+ {/if} +
+ {/snippet} + + + {#snippet referenceContent()} +
+
+

+ Relations +

+ {@render relationsContent()} +
+
+

+ Attributes +

+ {@render attributesContent()} +
+ {#if knowledge.length > 0} +
+

+ Knowledge +

+ {@render knowledgeContent()} +
+ {/if} + {#if agentActivity.length > 0 || auditEntries.length > 0} +
+

+ Audit +

+ {@render auditContent()} +
+ {/if} +
+ {/snippet} + + + {@const registry: Record = { + content: ownContent ? { title: 'Content', count: 1, content: contentContent } : null, + status: { + title: 'Status', + count: checks.filter((c) => c.enabled).length, + content: statusContent + }, + impact: { + title: 'Impact', + count: Math.max(blast.length - 1, 0), + content: impactContent + }, + activity: { + title: 'Activity', + count: openSignals.length + executions.length + tasks.length, + content: activityContent + }, + metrics: metrics.length + ? { title: 'Metrics', count: metrics.length, content: metricsContent } + : null, + reference: { + title: 'Reference', + count: outgoingRelations.length + incomingRelations.length, + content: referenceContent + } + }} + + {#each sectionsForType(entity.type) as key (key)} + {@const section = registry[key]} + {#if section} + 0)} + > + {@render section.content()} + + {/if} + {/each} + {/if} +
diff --git a/web/src/lib/components/EntityTable.svelte b/web/src/lib/components/EntityTable.svelte new file mode 100644 index 0000000..6ea6ff6 --- /dev/null +++ b/web/src/lib/components/EntityTable.svelte @@ -0,0 +1,258 @@ + + +{#if loading} +
+ + + + Slug + Type + Name + State + Health + + + + {#each skeletonSlugWidths as slugWidth, i} + + + + + + +
+ + +
+
+
+ {/each} +
+
+
+{:else} + {#snippet row(entity: Entity, level: number, ancestors: Set)} + {@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)} + {@const children = (childrenByParent.get(entity.slug) ?? []).filter( + (c) => !ancestorsWithSelf.has(c.slug) + )} + 0 ? !collapsedNodes.has(entity.slug) : undefined} + tabindex={0} + onclick={() => onSelect(entity.slug)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(entity.slug) + } + }} + > + + + + {#if children.length > 0} + + {/if} + + {entity.slug} + {#if children.length > 0} + ({children.length}) + {/if} + + + {entity.type} + {entity.name} + + + + + + + + {#if children.length > 0 && !collapsedNodes.has(entity.slug)} + {#each children as child (child.id)} + {@render row(child, level + 1, ancestorsWithSelf)} + {/each} + {/if} + {/snippet} +
+ + + + {@const ssSlug = getSortState('slug')} + + sortBy('slug')} + /> + + {@const ssType = getSortState('type')} + + sortBy('type')} + /> + + {@const ssName = getSortState('name')} + + sortBy('name')} + /> + + {@const ssState = getSortState('state')} + + sortBy('state')} + /> + + {@const ssHealth = getSortState('health')} + + sortBy('health')} + /> + + + + + {#each topLevelEntities as entity (entity.id)} + {@render row(entity, 1, new Set())} + {:else} + + {/each} + + +
+{/if} diff --git a/web/src/lib/components/FilterTabs.svelte b/web/src/lib/components/FilterTabs.svelte new file mode 100644 index 0000000..0952e72 --- /dev/null +++ b/web/src/lib/components/FilterTabs.svelte @@ -0,0 +1,42 @@ + + + + + {#each tabs as tab} + + {tab.label} + {#if tab.count != null && tab.count > 0} + + + + {/if} + + {/each} + + {#if children} + {@render children()} + {/if} + diff --git a/web/src/lib/components/FleetMap.svelte b/web/src/lib/components/FleetMap.svelte new file mode 100644 index 0000000..fb069ec --- /dev/null +++ b/web/src/lib/components/FleetMap.svelte @@ -0,0 +1,1311 @@ + + + + +{#if loading && !model} + +{:else if model} +
+ +
+ {#each HEALTH_ORDER as h} + {#if model.counts[h]} + + {/if} + {/each} +
+ + {#if problemMode || healthFilter} + + {/if} +
+
+ +
+ +
+
+ + + +
+ {#each [['Hosts', model.nodes.filter((n) => n.kind === 'host').length], ['Containers', model.nodes.filter((n) => n.kind === 'container').length], ['Services', model.nodes.filter((n) => n.kind === 'service').length]] as [label, count], i} +
+ {label} · {count} +
+ {/each} +
+ + + + {#each model.edges as e} + + {/each} + + + + {#each model.nodes as n (n.slug)} + + {/each} +
+
+ + + +
+ {#if !detailNode && problemMode} +
+
status
+
Problems
+
+
{problemCount}
+
{problemCount === 1 ? 'entity' : 'entities'} not healthy
+
+ {#each problemGroups as g} +
+

{HEALTH_LABEL[g.h]} · {g.items.length}

+ {#each g.items as it} + + {/each} +
+ {/each} +
+ {:else if !detailNode && healthFilter} +
+
status
+
{HEALTH_LABEL[healthFilter]}
+
+
{healthItems.length}
+
{healthItems.length === 1 ? 'entity' : 'entities'}
+
+
+

{HEALTH_LABEL[healthFilter]}

+ {#each healthItems as it} + + {/each} +
+
+ {:else if detailNode} +
+
{detailNode.slug}
+
{detailNode.name}
+ + {HEALTH_LABEL[detailNode.health]} + + +
+
0}>{detailBlast.length}
+
+ {detailNode.kind === 'service' + ? 'downstream service' + : 'service'}{detailBlast.length === 1 ? '' : 's'} + affected if this goes down +
+
+ +
+

Attributes

+ {#if detailNode.kind === 'container'} +
+ type{detailNode.type} +
+ {/if} + {#if detailNode.role}
+ role{detailNode.role} +
{/if} + {#if detailNode.ip}
+ ip{detailNode.ip} +
{/if} + {#if detailNode.publicHost} +
+ url{detailNode.publicHost} +
+
+ exposure{detailNode.fauth + ? 'forward-auth gated' + : detailNode.fauth === false + ? 'no auth gate' + : 'public'} +
+ {:else if detailNode.url} +
+ url{detailNode.url} +
+ {/if} + {#if detailNode.mounts.length}
+ mounts{detailNode.mounts.join(', ')} +
{/if} + {#if detailNode.repo}
+ config{detailNode.repo} +
{/if} +
+ + {#if detailRunsOn.length} +
+

Runs on

+ {#each detailRunsOn as it} + + {/each} +
+ {/if} + {#if detailDeps.length} +
+

Depends on

+ {#each detailDeps as it} + + {/each} +
+ {/if} + {#if detailProvides.length} +
+

{detailNode.kind === 'host' ? 'Hosts' : 'Provides'}

+ {#each detailProvides as it} + + {/each} +
+ {/if} + {#if detailNode.kind === 'service' && detailBlast.length} +
+

Depended on by

+ {#each detailBlast as it} + + {/each} +
+ {/if} +
+ {:else} + +
+

Health

+ {#each HEALTH_ORDER as h} +
+ {HEALTH_LABEL[h]} +
+ {/each} +
+

Lanes

+
Hosts — physical machines
+
+ Containers — LXCs & VMs +
+
+ Services — what you actually use +
+
+

+ Every service flows right from the machine that runs it. Hover any node to trace its + chain and blast radius — what breaks if it goes down. Click to open it in a window. + Dashed arcs are service-to-service dependencies. +

+
+ {/if} +
+
+
+{:else} +
+ Failed to load graph +
+{/if} + + diff --git a/web/src/lib/components/GlyphIndicator.svelte b/web/src/lib/components/GlyphIndicator.svelte new file mode 100644 index 0000000..9b33d66 --- /dev/null +++ b/web/src/lib/components/GlyphIndicator.svelte @@ -0,0 +1,89 @@ + + + + + \ No newline at end of file diff --git a/web/src/lib/components/OperatorQuestion.svelte b/web/src/lib/components/OperatorQuestion.svelte new file mode 100644 index 0000000..261029d --- /dev/null +++ b/web/src/lib/components/OperatorQuestion.svelte @@ -0,0 +1,89 @@ + + +{#if question} + {@const q = question} +
+
+ +
+

{q.prompt}

+ {#if q.context.why} +

{q.context.why}

+ {/if} +
+
+ + {#if q.context.entities?.length} +
+ {#each q.context.entities as slug} + {slug} + {/each} +
+ {/if} + + {#if q.context.options?.length} +
+ {#each q.context.options as opt} + + {/each} +
+ {/if} + +
+ diff --git a/web/src/lib/components/ui/tooltip/index.ts b/web/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..1d8d9cf --- /dev/null +++ b/web/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Root from './tooltip.svelte' +import Trigger from './tooltip-trigger.svelte' +import Content from './tooltip-content.svelte' +import Provider from './tooltip-provider.svelte' +import Portal from './tooltip-portal.svelte' + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal +} diff --git a/web/src/lib/components/ui/tooltip/tooltip-content.svelte b/web/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..dfa8b3d --- /dev/null +++ b/web/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
+ {/snippet} +
+
+
diff --git a/web/src/lib/components/ui/tooltip/tooltip-portal.svelte b/web/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000..6a4c585 --- /dev/null +++ b/web/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/tooltip/tooltip-provider.svelte b/web/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000..c09d6e4 --- /dev/null +++ b/web/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/web/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..ab4592b --- /dev/null +++ b/web/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/tooltip/tooltip.svelte b/web/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000..cf87c6c --- /dev/null +++ b/web/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 0000000..1c589eb --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,135 @@ +// Runtime configuration for the SPA — server URL + auth token. Every fetch +// call goes through fetchWithAuth/apiBase (used by api.ts) so the SPA works +// identically whether it's served same-origin (browser prod, Vite dev proxy) +// or cross-origin (Wails webview, remote access). See +// plans/2026-07-12-wails-desktop-app.md 0.2. + +import { isOIDCConfigured, ensureToken, logout as oidcLogout } from './oidc' + +export interface OikosConfig { + apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin + token?: string // bearer token for auth + isDesktop?: boolean // true when running inside the Wails desktop app +} + +declare global { + interface Window { + __OIKOS_CONFIG__?: OikosConfig + } +} + +let cfg: OikosConfig | undefined + +export function initConfig(override?: OikosConfig) { + cfg = override ?? window.__OIKOS_CONFIG__ + if (cfg?.token) { + localStorage.setItem('oikos_token', cfg.token) + if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl) + } +} + +export function getConfig(): OikosConfig { + if (!cfg) { + const token = localStorage.getItem('oikos_token') + const apiUrl = localStorage.getItem('oikos_api_url') + if (token || apiUrl) { + cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined } + } + } + return cfg ?? { apiUrl: '' } +} + +export function setConfig(next: OikosConfig) { + cfg = next + if (next.token) localStorage.setItem('oikos_token', next.token) + else localStorage.removeItem('oikos_token') + if (next.apiUrl) localStorage.setItem('oikos_api_url', next.apiUrl) + else localStorage.removeItem('oikos_api_url') +} + +export function clearConfig() { + cfg = { apiUrl: '' } + localStorage.removeItem('oikos_token') + localStorage.removeItem('oikos_api_url') +} + +export function isConfigured(): boolean { + return !!getConfig().token || isOIDCConfigured() +} + +// Relative paths are used in dev (Vite proxy) and when the SPA shares an +// origin with the API server (Caddy reverse proxy). Absolute paths are used +// when the API server is on a different origin (Wails webview, remote access). +export function apiBase(path: string): string { + const c = getConfig() + if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy + return `${c.apiUrl}${path}` +} + +// Resolves the auth token for a request: OIDC takes precedence, then static. +// OIDC is async (may need to refresh an expired access_token); the static +// fallback is synchronous. Returns the header value or null. +async function resolveAuthHeader(): Promise { + // Try OIDC first. ensureToken() refreshes if the cached token is expired or + // missing; if it returns a token we use it. + if (isOIDCConfigured()) { + const tok = await ensureToken() + if (tok) return `Bearer ${tok}` + // OIDC session exists but couldn't yield a usable token (e.g. expired + // access_token with no refresh_token). Fall through to the static token + // if one was configured — better than a blanket 401. + } + const c = getConfig() + if (c.token) return `Bearer ${c.token}` + return null +} + +// ---- Auth fetch wrapper ---- +// Prepends the API base URL (absolute when configured, relative when unset +// for the Vite dev proxy / same-origin prod) and adds the Authorization +// header. Used by every fetch call in api.ts. +// +// OIDC tokens are short-lived; this wrapper awaits ensureToken() so an +// expired access_token is refreshed before the request goes out, rather than +// 401ing on the wire. On a 401 we flush the OIDC session once so the next +// request can fall back to the static token (or re-prompt the operator). +export async function fetchWithAuth(path: string, opts?: RequestInit): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...((opts?.headers as Record) ?? {}) + } + const authH = await resolveAuthHeader() + if (authH) { + headers['Authorization'] = authH + } + + const res = await fetch(apiBase(path), { ...opts, headers }) + + // A 401 on a request we sent an Authorization header for means the token + // the server just rejected is no longer valid. If OIDC is in use, clear it + // so resolveAuthHeader() falls back to the static token next time (or the + // operator gets re-prompted to log in). Don't loop: only one flush, and + // only when we actually sent an Authorization header. + if (res.status === 401 && authH && isOIDCConfigured()) { + oidcLogout() + } + return res +} + +// SSE path builder — EventSource doesn't take headers, so pass the token as +// a query parameter (the SSE handler's combinedAuth checks it alongside the +// Authorization header, only for this route). Async so the OIDC access token +// can be refreshed before the EventSource is constructed. +export async function sseUrl(path: string): Promise { + const url = apiBase(path) + const c = getConfig() + // Prefer a fresh OIDC token (refreshes if expired); fall back to the static token. + let token: string | null = null + if (isOIDCConfigured()) { + token = await ensureToken() + } + if (!token) token = c.token ?? null + if (!token) return url + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}token=${encodeURIComponent(token)}` +} diff --git a/web/src/lib/desktop-patterns.ts b/web/src/lib/desktop-patterns.ts new file mode 100644 index 0000000..4ef438c --- /dev/null +++ b/web/src/lib/desktop-patterns.ts @@ -0,0 +1,84 @@ +// Desktop background patterns — CSS-only (no images), in the spirit of +// magicpattern.design's "CSS backgrounds" gallery. Each pattern is written +// with a single foreground color against `transparent` gaps rather than a +// baked-in second color, so it composes correctly over the app's own +// terracotta/carbon theme background (and follows the theme swap for free) +// instead of needing its own light/dark variant. A separate, optional fill +// color (see background.svelte.ts) sits underneath as `background-color`, +// showing through the transparent gaps when the user wants one. + +export type BackgroundPatternId = + 'none' | 'dots' | 'bubbles' | 'grid' | 'stripes' | 'checks' | 'zigzag' | 'rings' + +export interface PatternDef { + id: BackgroundPatternId + label: string + // Returns a CSS declaration string (background-image/-size/-position/ + // -repeat) for the given foreground color and size multiplier (1 = the + // pattern's natural/default tile size). Empty string = no pattern. + css: (color: string, scale: number) => string +} + +// Rounds to 1 decimal — enough precision for a smooth size slider without +// producing long floating-point tails in the generated CSS. +function px(base: number, scale: number): string { + return `${Math.round(Math.max(0.5, base * scale) * 10) / 10}px` +} + +export const PATTERNS: PatternDef[] = [ + { id: 'none', label: 'None', css: () => '' }, + { + id: 'dots', + label: 'Dots', + css: (c, s) => + `background-image: radial-gradient(${c} ${px(1.6, s)}, transparent ${px(1.6, s)}); background-size: ${px(18, s)} ${px(18, s)};` + }, + { + id: 'bubbles', + label: 'Bubbles', + css: (c, s) => + `background-image: radial-gradient(${c} 17%, transparent 18% 35%, transparent 36.5%), radial-gradient(${c} 17%, transparent 18% 35%, transparent 36.5%), radial-gradient(transparent 34%, ${c} 36% 68%, transparent 70%), repeating-linear-gradient(45deg, ${c} -12.5% 12.5%, transparent 0 37.5%); background-position: -${px(20, s)} -${px(20, s)}, ${px(20, s)} ${px(20, s)}, 0 0, 0 0; background-size: ${px(80, s)} ${px(80, s)}, ${px(80, s)} ${px(80, s)}, ${px(40, s)} ${px(40, s)}, ${px(80, s)} ${px(80, s)};` + }, + { + id: 'grid', + label: 'Grid', + css: (c, s) => + `background-image: linear-gradient(${c} 1px, transparent 1px), linear-gradient(90deg, ${c} 1px, transparent 1px); background-size: ${px(24, s)} ${px(24, s)};` + }, + { + id: 'stripes', + label: 'Stripes', + css: (c, s) => + `background-image: repeating-linear-gradient(45deg, ${c} 0 ${px(2, s)}, transparent ${px(2, s)} ${px(14, s)});` + }, + { + id: 'checks', + label: 'Checks', + css: (c, s) => + `background-image: conic-gradient(${c} 90deg, transparent 90deg 180deg, ${c} 180deg 270deg, transparent 270deg); background-size: ${px(24, s)} ${px(24, s)};` + }, + { + id: 'zigzag', + label: 'Zigzag', + css: (c, s) => + `background-image: linear-gradient(135deg, ${c} 25%, transparent 25%), linear-gradient(225deg, ${c} 25%, transparent 25%), linear-gradient(45deg, ${c} 25%, transparent 25%), linear-gradient(315deg, ${c} 25%, transparent 25%); background-position: ${px(20, s)} 0, ${px(20, s)} 0, 0 0, 0 0; background-size: ${px(40, s)} ${px(40, s)}; background-repeat: repeat;` + }, + { + id: 'rings', + label: 'Rings', + css: (c, s) => + `background-image: repeating-radial-gradient(circle at 50% 50%, ${c} 0, ${c} ${px(1, s)}, transparent ${px(1, s)}, transparent ${px(14, s)});` + } +] + +const byId = new Map(PATTERNS.map((p) => [p.id, p])) + +export function patternDef(id: BackgroundPatternId): PatternDef { + return byId.get(id) ?? PATTERNS[0] +} + +// Full inline-style CSS text for a pattern + color + size, ready to drop +// into a `style` attribute. Empty string for 'none' (or unknown ids). +export function patternCss(id: BackgroundPatternId, color: string, scale = 1): string { + return patternDef(id).css(color, scale) +} diff --git a/web/src/lib/entityView.test.ts b/web/src/lib/entityView.test.ts new file mode 100644 index 0000000..592dbc4 --- /dev/null +++ b/web/src/lib/entityView.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest' +import { deriveVerdict, sectionsForType, isObservable, checkLabel, nomosPrompt } from './entityView' +import type { Check, Entity } from './api' + +function entity(partial: Partial = {}): Entity { + return { + id: 'e1', + slug: 'host:strong', + type: 'proxmox-host', + name: 'strong', + attributes: {}, + version: 1, + created_at: '', + updated_at: '', + ...partial + } as Entity +} + +function check(partial: Partial = {}): Check { + return { + id: 'c' + Math.random(), + slug: 'check:x', + kind: 'ssh-script', + interval_s: 60, + timeout_s: 30, + enabled: true, + version: 1, + ...partial + } as Check +} + +describe('deriveVerdict', () => { + // The case that motivated the redesign: host:strong reads down because one + // ping probe fails from the scheduler's network vantage point, while five + // ssh checks pass. The window must say that, not just "down". + it('names the failing probe rather than counting', () => { + const v = deriveVerdict(entity({ health: 'down' }), [ + check({ kind: 'ping', last_health: 'down' }), + check({ config: { script: 'cpu_check.sh' }, last_health: 'healthy' }), + check({ config: { script: 'memory_check.sh' }, last_health: 'healthy' }), + check({ config: { script: 'load_check.sh' }, last_health: 'healthy' }), + check({ config: { script: 'disk_usage_check.sh' }, last_health: 'healthy' }), + check({ config: { script: 'updates_check.sh' }, last_health: 'healthy' }) + ]) + expect(v.health).toBe('down') + expect(v.reason).toContain('ping') + expect(v.reason).toContain('5 of 6 checks passing') + expect(v.failing).toHaveLength(1) + }) + + it('says nothing when everything passes', () => { + const v = deriveVerdict(entity({ health: 'healthy' }), [ + check({ last_health: 'healthy' }), + check({ last_health: 'healthy' }) + ]) + expect(v.reason).toBe('') + expect(v.passing).toBe(2) + }) + + // "No checks" is a coverage gap, not good news — calling it healthy would be + // a lie by omission, and it is exactly what the unmonitored signal reports. + it('distinguishes unmonitored from healthy', () => { + const v = deriveVerdict(entity(), []) + expect(v.health).toBe('unmonitored') + expect(v.reason).toContain('no checks') + }) + + it('ignores disabled checks', () => { + const v = deriveVerdict(entity({ health: 'healthy' }), [ + check({ last_health: 'healthy' }), + check({ kind: 'ping', last_health: 'down', enabled: false }) + ]) + expect(v.failing).toHaveLength(0) + expect(v.total).toBe(1) + }) + + it('reports checks that have not run yet without calling them failures', () => { + const v = deriveVerdict(entity({ health: 'healthy' }), [ + check({ last_health: 'healthy' }), + check({ config: { script: 'updates_check.sh' } }) // never run + ]) + expect(v.failing).toHaveLength(0) + expect(v.reason).toContain('not yet run') + }) + + it('orders failures worst-first so the reason leads with the worst', () => { + const v = deriveVerdict(entity({ health: 'down' }), [ + check({ config: { script: 'disk_usage_check.sh' }, last_health: 'degraded' }), + check({ kind: 'ping', last_health: 'down' }) + ]) + expect(v.failing[0].kind).toBe('ping') + expect(v.reason.startsWith('ping')).toBe(true) + }) + + it('survives a null entity', () => { + expect(deriveVerdict(null, []).health).toBe('unknown') + }) +}) + +describe('checkLabel', () => { + it('prefers the script name over the generic kind', () => { + expect(checkLabel(check({ config: { script: 'cpu_check.sh' } }))).toBe('cpu_check') + expect(checkLabel(check({ kind: 'ping' }))).toBe('ping') + }) +}) + +describe('sectionsForType', () => { + it('gives infrastructure the triage ordering, status first', () => { + expect(sectionsForType('proxmox-host')[0]).toBe('status') + expect(sectionsForType('service')).toContain('impact') + }) + + // A document has no checks or metrics; offering those sections is noise. + it('leads knowledge types with their content and offers no monitoring', () => { + const s = sectionsForType('document') + expect(s[0]).toBe('content') + expect(s).not.toContain('status') + expect(s).not.toContain('metrics') + }) + + it('gives record types a minimal view', () => { + expect(sectionsForType('execution')).not.toContain('status') + expect(sectionsForType('signal')).not.toContain('metrics') + }) + + // A new entity type must never produce a blank window. + it('falls back to infrastructure for unknown types', () => { + expect(sectionsForType('something-new')).toEqual(sectionsForType('lxc')) + }) +}) + +describe('isObservable', () => { + it('separates things that can be probed from records and documents', () => { + expect(isObservable('lxc')).toBe(true) + expect(isObservable('document')).toBe(false) + expect(isObservable('execution')).toBe(false) + }) +}) + +describe('nomosPrompt', () => { + it('scopes the task to the failure when there is one', () => { + const v = deriveVerdict(entity({ health: 'down' }), [ + check({ kind: 'ping', last_health: 'down' }), + check({ last_health: 'healthy' }) + ]) + const p = nomosPrompt(entity({ health: 'down' }), v) + expect(p).toContain('host:strong') + expect(p).toContain('ping') + }) + + it('asks about coverage when nothing is monitored', () => { + const v = deriveVerdict(entity(), []) + expect(nomosPrompt(entity(), v)).toContain('no checks') + }) +}) diff --git a/web/src/lib/entityView.ts b/web/src/lib/entityView.ts new file mode 100644 index 0000000..5b18de0 --- /dev/null +++ b/web/src/lib/entityView.ts @@ -0,0 +1,169 @@ +// What an entity window should say, and which sections it should show. +// +// The window used to render the same 13 collapsible sections for every entity, +// sorted only by "does it have content" — so a host with 223 relations and +// 2.7M metric samples looked exactly like an ingress route with three facts, +// and Audit trail carried the same visual weight as Health. Worse, it could +// never say *why* something was unhealthy: it rendered checks as configuration +// ("ssh-script, every 60s, enabled") rather than as results. +// +// Both problems are decided here, as pure functions, so they can be tested +// without a browser and without a database. + +import type { Check, Entity, EntityHealth } from '$lib/api' + +// ─── Verdict ────────────────────────────────────────────────────────────── + +export interface Verdict { + health: EntityHealth | 'unmonitored' + /** One line explaining the health, or '' when there is nothing to explain. */ + reason: string + /** Checks whose own verdict is worse than healthy, worst first. */ + failing: Check[] + passing: number + total: number +} + +const SEVERITY: Record = { + down: 0, + degraded: 1, + stale: 2, + unknown: 3, + healthy: 4 +} + +function severity(h: string | null | undefined): number { + return SEVERITY[h ?? 'unknown'] ?? 3 +} + +/** What a check is actually probing, for use in a human-readable reason. */ +export function checkLabel(check: Check): string { + const script = (check.config as Record | undefined)?.script + if (typeof script === 'string' && script) return script.replace(/\.sh$/, '') + return check.kind +} + +/** + * Derives the entity's health and a one-line reason from its own checks. + * + * Mirrors the backend's aggregation (WorstHealthForTarget): an entity is as + * healthy as its unhealthiest check. Deriving it here as well means the header + * can name the responsible probe, which the entity's stored health alone can + * never do. + */ +export function deriveVerdict(entity: Entity | null, checks: Check[]): Verdict { + const enabled = checks.filter((c) => c.enabled) + const withVerdict = enabled.filter((c) => c.last_health) + + if (!entity) { + return { health: 'unknown', reason: '', failing: [], passing: 0, total: 0 } + } + + // No checks at all is a distinct state from "checks that all pass" — it is + // the coverage gap the unmonitored signal reports, and saying "healthy" + // here would be a lie by omission. + if (enabled.length === 0) { + return { + health: 'unmonitored', + reason: 'no checks configured for this entity', + failing: [], + passing: 0, + total: 0 + } + } + + const failing = withVerdict + .filter((c) => c.last_health && c.last_health !== 'healthy') + .sort((a, b) => severity(a.last_health) - severity(b.last_health)) + const passing = withVerdict.length - failing.length + + // Prefer the entity's stored health (the backend is authoritative and + // accounts for staleness), falling back to the derived worst. + const health: EntityHealth = + entity.health ?? (failing[0]?.last_health as EntityHealth) ?? 'unknown' + + if (failing.length === 0) { + const pending = enabled.length - withVerdict.length + return { + health, + reason: + pending > 0 ? `${passing} of ${enabled.length} checks passing, ${pending} not yet run` : '', + failing, + passing, + total: enabled.length + } + } + + // Name the probes, not the count: "ping failing" is actionable in a way that + // "1 check failing" is not. + const names = failing.slice(0, 2).map(checkLabel) + const more = failing.length - names.length + const who = names.join(', ') + (more > 0 ? ` +${more} more` : '') + const verb = failing[0].last_health === 'down' ? 'failing' : failing[0].last_health + + return { + health, + reason: `${who} ${verb} · ${passing} of ${enabled.length} checks passing`, + failing, + passing, + total: enabled.length + } +} + +// ─── Section composition ────────────────────────────────────────────────── + +export type SectionKey = 'content' | 'status' | 'impact' | 'activity' | 'metrics' | 'reference' + +// Knowledge entities are documents: their content is the point, and they have +// no checks, metrics or signals to show. +const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook']) + +// Records of something that happened, not things that can be observed. +// Offering them monitoring sections is meaningless. +const RECORD_TYPES = new Set([ + 'execution', + 'signal', + 'check', + 'approval', + 'classification', + 'feedback', + 'pattern', + 'skill' +]) + +const INFRASTRUCTURE: SectionKey[] = ['status', 'impact', 'activity', 'metrics', 'reference'] +const KNOWLEDGE: SectionKey[] = ['content', 'impact', 'reference'] +const RECORD: SectionKey[] = ['activity', 'reference'] + +/** + * The sections this entity type should show, in order. + * + * Unknown types fall back to the infrastructure list rather than rendering + * nothing, so a newly added entity type is never a blank window. + */ +export function sectionsForType(type: string): SectionKey[] { + if (KNOWLEDGE_TYPES.has(type)) return KNOWLEDGE + if (RECORD_TYPES.has(type)) return RECORD + return INFRASTRUCTURE +} + +/** Whether this type is worth showing monitoring affordances for at all. */ +export function isObservable(type: string): boolean { + return !KNOWLEDGE_TYPES.has(type) && !RECORD_TYPES.has(type) +} + +// ─── Ask Nomos ──────────────────────────────────────────────────────────── + +/** + * A task prompt scoped to what the operator is currently looking at, so + * investigating is one click from seeing rather than a retyped question. + */ +export function nomosPrompt(entity: Entity, verdict: Verdict): string { + if (verdict.health === 'unmonitored') { + return `${entity.slug} has no checks configured. Work out what monitoring it should have and set it up.` + } + if (verdict.failing.length > 0) { + return `Investigate ${entity.slug} — it reads ${verdict.health}: ${verdict.reason}. Find the cause and report what you find.` + } + return `Give me a status summary of ${entity.slug}: what it is, what depends on it, and anything that looks off.` +} diff --git a/web/src/lib/health.test.ts b/web/src/lib/health.test.ts new file mode 100644 index 0000000..7b7e642 --- /dev/null +++ b/web/src/lib/health.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest' +import { isHealthEvent, healthFromEvent, applyHealthEvent, applyHealthEventTo } from './health' +import type { Entity } from './api' +import type { OikosEvent } from './stores/events' + +function ev(partial: Partial): OikosEvent { + return { + id: 1, + ts: '2026-07-28T12:00:00Z', + type: 'health.changed', + entity_id: 'e1', + severity: 'info', + source: 'scheduler', + data: { to: 'degraded' }, + correlation_id: null, + ...partial + } as OikosEvent +} + +function entity(partial: Partial = {}): Entity { + return { + id: 'e1', + slug: 'lxc:apps', + type: 'lxc', + name: 'apps', + attributes: {}, + version: 1, + created_at: '', + updated_at: '', + health: 'healthy', + ...partial + } as Entity +} + +describe('health event application', () => { + it('reads the new health out of the payload', () => { + expect(healthFromEvent(ev({}))).toBe('degraded') + expect(healthFromEvent(ev({ type: 'health.stale', data: { to: 'stale' } }))).toBe('stale') + }) + + it('ignores events that are not health events', () => { + expect(isHealthEvent(ev({ type: 'signal.raised' }))).toBe(false) + expect(healthFromEvent(ev({ type: 'execution.output' }))).toBeNull() + }) + + it('ignores a health event with no usable payload', () => { + expect(healthFromEvent(ev({ data: {} }))).toBeNull() + expect(healthFromEvent(ev({ entity_id: null }))).toBeNull() + }) + + it('patches the matching entity in a list and leaves the rest alone', () => { + const list = [entity(), entity({ id: 'e2', slug: 'lxc:dns', health: 'healthy' })] + const next = applyHealthEvent(list, ev({})) + expect(next[0].health).toBe('degraded') + expect(next[0].last_check_at).toBe('2026-07-28T12:00:00Z') + // untouched entities keep their identity, so rows that did not change do + // not re-render + expect(next[1]).toBe(list[1]) + }) + + // Returning the same array reference matters: assigning a fresh array on + // every unrelated event would churn the whole table. + it('returns the original array when the event does not apply', () => { + const list = [entity()] + expect(applyHealthEvent(list, ev({ entity_id: 'nobody' }))).toBe(list) + expect(applyHealthEvent(list, ev({ type: 'signal.raised' }))).toBe(list) + expect(applyHealthEvent(list, ev({ data: { to: 'healthy' } }))).toBe(list) // already healthy + }) + + it('patches a single entity for a detail view', () => { + const e = entity() + const next = applyHealthEventTo(e, ev({})) + expect(next?.health).toBe('degraded') + expect(applyHealthEventTo(e, ev({ entity_id: 'other' }))).toBe(e) + expect(applyHealthEventTo(null, ev({}))).toBeNull() + }) + + // The scheduler emits health.changed with entity_id = the observed entity + // but data.slug = the *check's* slug, so slug must never be used to match. + it('matches on entity_id, never on the payload slug', () => { + const list = [entity()] + const next = applyHealthEvent(list, ev({ data: { to: 'down', slug: 'check:ping:whatever:0' } })) + expect(next[0].health).toBe('down') + }) +}) diff --git a/web/src/lib/health.ts b/web/src/lib/health.ts new file mode 100644 index 0000000..4393f27 --- /dev/null +++ b/web/src/lib/health.ts @@ -0,0 +1,54 @@ +// Applying health events in place. +// +// health.changed / health.stale already carry the new value in their payload, +// so a view that renders health does not need to refetch anything: it can +// patch the one entity it already holds. Refetching the whole fleet because a +// single container went degraded costs a full round trip, re-runs the parent +// grouping, and churns the table on every transition — for information the +// event had already delivered. +// +// NOTE: match on entity_id, never on data.slug. The scheduler emits +// health.changed with entity_id = the observed entity but data.slug = the +// *check's* slug (e.g. "check:ping:host:hubris:0"), so the slug in the +// payload does not identify the thing whose health changed. + +import type { Entity, EntityHealth } from '$lib/api' +import type { OikosEvent } from '$lib/stores/events' + +const HEALTH_EVENTS = new Set(['health.changed', 'health.stale']) + +export function isHealthEvent(ev: OikosEvent): boolean { + return HEALTH_EVENTS.has(ev.type) +} + +/** The new health an event reports, or null if it isn't a usable health event. */ +export function healthFromEvent(ev: OikosEvent): EntityHealth | null { + if (!isHealthEvent(ev) || !ev.entity_id) return null + const to = (ev.data as Record | undefined)?.to + return typeof to === 'string' ? (to as EntityHealth) : null +} + +/** + * Returns a copy of `list` with the event's entity patched, or the original + * array when the event doesn't apply — so callers can assign unconditionally + * without forcing a re-render for an entity they aren't showing. + */ +export function applyHealthEvent(list: Entity[], ev: OikosEvent): Entity[] { + const health = healthFromEvent(ev) + if (!health) return list + const i = list.findIndex((e) => e.id === ev.entity_id) + if (i < 0) return list + if (list[i].health === health) return list + const next = list.slice() + // ev.ts is when the check ran, which is exactly what "checked N ago" means. + next[i] = { ...next[i], health, last_check_at: ev.ts } + return next +} + +/** Single-entity form, for a detail view holding one entity. */ +export function applyHealthEventTo(entity: Entity | null, ev: OikosEvent): Entity | null { + if (!entity || ev.entity_id !== entity.id) return entity + const health = healthFromEvent(ev) + if (!health || entity.health === health) return entity + return { ...entity, health, last_check_at: ev.ts } +} diff --git a/web/src/lib/hooks/is-mobile.svelte.ts b/web/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..e0c6ecd --- /dev/null +++ b/web/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from 'svelte/reactivity' + +const DEFAULT_MOBILE_BREAKPOINT = 768 + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`) + } +} diff --git a/web/src/lib/mascot/Mascot.svelte b/web/src/lib/mascot/Mascot.svelte new file mode 100644 index 0000000..5a3b57f --- /dev/null +++ b/web/src/lib/mascot/Mascot.svelte @@ -0,0 +1,905 @@ + + + + + + +{#if runtime.bubbleText || model.name} +
+ {#if runtime.bubbleText} + {#key runtime.bubbleText} +
+
+ {typedText}{#if !typingDone}{/if} +
+
+
+ {/key} + {:else} +
+ {model.name} +
+ {/if} +
+{/if} + + diff --git a/web/src/lib/mascot/MascotLayer.svelte b/web/src/lib/mascot/MascotLayer.svelte new file mode 100644 index 0000000..2b9423a --- /dev/null +++ b/web/src/lib/mascot/MascotLayer.svelte @@ -0,0 +1,243 @@ + + +
+ + + + { + nameDialogMode = 'hatch' + nameDialogOpen = true + }} + /> + + + + + +
+ +{#if nameDialogOpen} + (nameDialogOpen = false)} + /> +{/if} diff --git a/web/src/lib/mascot/MascotMenu.svelte b/web/src/lib/mascot/MascotMenu.svelte new file mode 100644 index 0000000..afa4276 --- /dev/null +++ b/web/src/lib/mascot/MascotMenu.svelte @@ -0,0 +1,55 @@ + + +{#each visible as a (a.id)} + {#if a.children && a.children.length > 0} + + + {#if a.icon}{/if} + {a.label} + + + + + + {:else} + { + a.action?.(ctx) + }} + > + {#if a.icon}{/if} + + {a.label} + {#if a.description} + {a.description} + {/if} + + + {/if} +{/each} diff --git a/web/src/lib/mascot/NameDialog.svelte b/web/src/lib/mascot/NameDialog.svelte new file mode 100644 index 0000000..bfe7bc8 --- /dev/null +++ b/web/src/lib/mascot/NameDialog.svelte @@ -0,0 +1,98 @@ + + + + + + diff --git a/web/src/lib/mascot/actions.ts b/web/src/lib/mascot/actions.ts new file mode 100644 index 0000000..72b10b2 --- /dev/null +++ b/web/src/lib/mascot/actions.ts @@ -0,0 +1,272 @@ +// Radial menu action tree. To add a new menu action: +// - Add a `RadialAction` node to MASCOT_ACTIONS below (or call +// `registerMascotAction(a, parentId)` at runtime to insert under an +// existing node). +// - The leaf `action(ctx)` mutates the model/runtime via ctx; nested +// `children` render as a sub-ring. +// - `visible(model)` gates visibility (e.g. "Rename" only once hatched). +// RadialMenu.svelte renders whatever tree it's given, including +// arbitrary nesting depth — no engine change is needed for a new node. + +import type { MascotActionCtx, RadialAction } from './types' +import { REACTIONS } from './stimuli' + +// v1 tree: Interact [Pet, Feed → [Seeds, Worm]], Care [Sleep, Wake], +// Identity [Rename], Debug [Lifecycle → [...], Reactions → [...], Force +// fall, Reset]. The Pet action is the same as a plain click — included in +// the menu for discoverability. +// +// Every Debug leaf carries a `description` naming the real, non-debug +// trigger it's simulating (rendered as a muted second line by +// RadialMenu.svelte) — the point of a debug menu is to let you fire +// something without waiting for the real condition, but that's only +// useful for testing if you also know what condition it's standing in for. + +/** Play a REACTIONS entry (see stimuli.ts) exactly as the real stimulus bus would — same anim/bubble/duration/effect — without needing to fake the chat/activity/event stream that normally triggers it. */ +function triggerReaction(ctx: MascotActionCtx, id: keyof typeof REACTIONS): void { + const r = REACTIONS[id] + ctx.runtime.bubbleText = r.bubble ?? null + ctx.runtime.bubbleUntil = performance.now() + r.durationMs + ctx.force('react', { anim: r.anim, durationMs: r.durationMs }) + r.effect?.() +} + +/** Show a speech-bubble line (text and/or emoji — see Mascot.svelte's template) above the mascot for `ms`. */ +function showBubble(ctx: MascotActionCtx, text: string, ms: number): void { + ctx.runtime.bubbleText = text + ctx.runtime.bubbleUntil = performance.now() + ms +} + +export const MASCOT_ACTIONS: RadialAction[] = [ + { + id: 'interact', + label: 'Interact', + children: [ + { + id: 'pet', + label: 'Pet', + action: (ctx) => { + showBubble(ctx, '❤️', 1500) + ctx.force('react', { anim: 'react-happy', durationMs: 1500 }) + ctx.refresh() + } + }, + { + id: 'feed', + label: 'Feed', + children: [ + { + id: 'feed-seeds', + label: 'Seeds', + action: (ctx) => { + // Feeding: small happiness + xp boost. + ctx.model.happiness = Math.min(100, ctx.model.happiness + 8) + showBubble(ctx, '🌾 Yum!', 1800) + // Force the dedicated 'peck' behavior, not 'idle' with an anim + // override — stepMascot() re-derives the anim from the active + // behavior's own anim() every tick (except for 'react', which + // has a special-cased override via reactAnim), so an anim + // override on any other behavior gets silently clobbered + // within one frame. 'peck' always resolves to the peck anim. + ctx.force('peck', { durationMs: 1800 }) + ctx.refresh() + } + }, + { + id: 'feed-worm', + label: 'Worm', + action: (ctx) => { + // Worm: bigger boost. + ctx.model.happiness = Math.min(100, ctx.model.happiness + 16) + showBubble(ctx, '🐛 Yum!', 1800) + // Force the dedicated 'peck' behavior, not 'idle' with an anim + // override — stepMascot() re-derives the anim from the active + // behavior's own anim() every tick (except for 'react', which + // has a special-cased override via reactAnim), so an anim + // override on any other behavior gets silently clobbered + // within one frame. 'peck' always resolves to the peck anim. + ctx.force('peck', { durationMs: 1800 }) + ctx.refresh() + } + } + ] + } + ] + }, + { + id: 'care', + label: 'Care', + children: [ + { + id: 'sleep', + label: 'Sleep', + visible: (m) => m.stage !== 'egg', + action: (ctx) => { + showBubble(ctx, '😴 Zzz…', 2000) + ctx.force('sleep', { durationMs: 8000 }) + ctx.refresh() + } + }, + { + id: 'wake', + label: 'Wake', + visible: () => true, // visible always; only useful when asleep but harmless otherwise + action: (ctx) => { + ctx.force('idle') + ctx.refresh() + } + } + ] + }, + { + id: 'identity', + label: 'Identity', + children: [ + { + id: 'rename', + label: 'Rename', + visible: (m) => m.stage !== 'egg', + action: (ctx) => { + ctx.requestRename() + ctx.refresh() + } + } + ] + }, + { + id: 'debug', + label: 'Debug', + children: [ + { + id: 'debug-lifecycle', + label: 'Lifecycle', + children: [ + { + id: 'force-hatch', + label: 'Force hatch', + description: + 'Normally fires the instant you submit a name for a fresh egg — hatching is tied to naming, not a timer.', + visible: (m) => m.stage === 'egg', + action: (ctx) => { + ctx.forceHatch() + ctx.refresh() + } + }, + { + id: 'force-chick', + label: 'Force chick', + description: + 'Sets the stage directly. Normally happens automatically the moment a freshly-named egg hatches.', + visible: (m) => m.stage !== 'chick', + action: (ctx) => { + ctx.forceStage('chick') + ctx.force('idle') + ctx.refresh() + } + }, + { + id: 'force-adult', + label: 'Force adult', + description: + 'Sets the stage directly. Normally happens once xp reaches 200 — earned by petting, feeding, and reactions like Eureka.', + visible: (m) => m.stage !== 'adult', + action: (ctx) => { + ctx.forceStage('adult') + ctx.force('idle') + ctx.refresh() + } + } + ] + }, + { + id: 'debug-reactions', + label: 'Reactions', + visible: (m) => m.stage !== 'egg', // reactions are suppressed while an egg — see MascotLayer's attachStimuli callback + children: [ + { + id: 'trigger-thinking', + label: 'Trigger: Thinking', + description: + 'Normally a continuous state (not a timed pulse like this button): the focused task window\'s session starts streaming a reply, before any text has arrived yet. Switches to the "talk" sprite the moment text starts appearing.', + action: (ctx) => { + triggerReaction(ctx, 'thinking') + ctx.refresh() + } + }, + { + id: 'trigger-eureka', + label: 'Trigger: Eureka', + description: + 'Normally fires when the focused task window records new knowledge (an "upsert_knowledge" tool result) — the real bubble shows the knowledge\'s own title. Grants +5 xp.', + action: (ctx) => { + triggerReaction(ctx, 'eureka') + ctx.refresh() + } + }, + { + id: 'trigger-alarmed', + label: 'Trigger: Alarmed', + description: + 'Normally fires when the focused task window raises a new operator question (permission needed to proceed). The only reaction that wakes the mascot from sleep.', + action: (ctx) => { + triggerReaction(ctx, 'alarmed') + ctx.refresh() + } + }, + { + id: 'trigger-happy', + label: 'Trigger: Happy', + description: + "Normally fires when the focused task window's task completes successfully — the real bubble shows the task's own summary.", + action: (ctx) => { + triggerReaction(ctx, 'happy') + ctx.refresh() + } + } + ] + }, + { + id: 'force-fall', + label: 'Force fall', + description: + 'Normally starts when you drop the mascot mid-air, it walks off the edge of a window, or the surface beneath it disappears (a window closes or moves away). Lifts it up first so there’s room to actually fall.', + visible: (m) => m.stage !== 'egg', + action: (ctx) => { + ctx.runtime.y = Math.max(0, ctx.runtime.y - 160) + ctx.force('falling') + ctx.refresh() + } + }, + { + id: 'reset', + label: 'Reset', + description: + 'No natural trigger — clears all mascot state (stage, name, stats, position) back to a fresh, unnamed egg.', + action: (ctx) => { + ctx.reset() + ctx.refresh() + } + } + ] + } +] + +/** Insert an action at runtime, optionally nested under a parent id. Root insertion if parentId is undefined. */ +export function registerMascotAction(a: RadialAction, parentId?: string): void { + if (!parentId) { + MASCOT_ACTIONS.push(a) + return + } + function findAndInsert(nodes: RadialAction[]): boolean { + for (const n of nodes) { + if (n.id === parentId) { + n.children = n.children ?? [] + n.children.push(a) + return true + } + if (n.children && findAndInsert(n.children)) return true + } + return false + } + findAndInsert(MASCOT_ACTIONS) +} diff --git a/web/src/lib/mascot/behavior.ts b/web/src/lib/mascot/behavior.ts new file mode 100644 index 0000000..585f126 --- /dev/null +++ b/web/src/lib/mascot/behavior.ts @@ -0,0 +1,631 @@ +// Behavior engine: a finite state machine that drives the mascot's +// autonomous motion + animation. To add a behavior: +// 1. Add its id to `BehaviorId` in types.ts. +// 2. Add a `BehaviorDef` entry to BEHAVIORS below. +// 3. (Optional) Give it a `weight` to make it idle-selectable. +// `stepMascot()` and the weighted-random idle selector consume +// BEHAVIORS generically — no engine change is needed for a new behavior. +// +// Behaviors split into two groups: +// - Self-selecting (idle/wander/peck/sleep): pickable by the weighted +// random idle selector when the current behavior expires. +// - Forced (dragged/falling/react/land): entered only via forceBehavior() +// from the pointer code, gravity logic, or the stimulus bus. +// +// Physics: gravity + ground. GROUND_Y = bounds.h (the surface's bottom +// edge, == the taskbar's top edge). When above ground and not dragged, +// the mascot falls with a slow flutter terminal velocity — but the fall +// is alive: panic-flap wing-beats slow the descent on a speed-scaled, +// jittered cycle, faster-than-terminal tosses decay under drag, hard +// impacts bounce once and skid, and hard sideways throws ricochet off +// the surface's side bounds. On landing, a brief `land` behavior plays +// (the squash/spring render layer keys off rt.squashAt/impactVy), then +// idle. Dragging is always honored — pointer code calls +// forceBehavior('dragged'), which wins over any autonomous behavior or +// non-drag-breaking reaction. + +import type { AnimName, BehaviorId, MascotRuntime } from './types' +import type { MascotModel } from './state.svelte' + +// ─── tuning constants ──────────────────────────────────────────────────── + +const GRAVITY = 1400 // px/s^2 (gentle) +const TERMINAL_VY = 320 // px/s (slow flutter fall) +// A fall can exceed TERMINAL_VY (a hard downward toss); instead of a +// hard clamp, drag pulls it back toward terminal at this rate, so a +// fling reads fast-then-settling instead of unnaturally capped. +const SUPER_TERMINAL_DRAG = 1000 // px/s^2 +const WALK_SPEED = 36 // px/s +const MARGIN = 24 // px before the surface edge where wander flips facing + +// Falling is a series of glide/flap sub-phases, not a flat monotonic drop: +// every flapCycleMs, a wing-beat impulse briefly cuts the descent speed +// (a real, if losing, attempt at flight), and the animation swaps to `flap` +// for the FLAP_BURST_MS right after each impulse. The cycle is DYNAMIC — +// the faster the descent, the more frantic the flapping (scheduleFlap +// below), with jitter so it never reads metronomic. A gentle sine wobble +// adds horizontal drift so the fall isn't perfectly vertical either. See +// stepMascot()'s 'falling' case and the `falling` BehaviorDef below. +const FLAP_CYCLE_MIN_MS = 330 // frantic (fast descent) +const FLAP_CYCLE_MAX_MS = 600 // lazy (slow flutter) +const FLAP_BURST_MS = 160 +const FLAP_IMPULSE = 260 // px/s shaved off vy at the start of each cycle +const FLAP_MAX_LIFT = -150 // px/s — how negative (upward) a flap may push vy +const WOBBLE_VX = 22 // px/s amplitude of the sideways drift while falling +const VX_DECAY_PER_S = 1.4 // exponential decay rate for toss/drift vx + +// Impact bounce: a round fluffy body, not a rock. A hard-enough impact +// bounces once (diminished), then lands. The contact squash is rendered +// by Mascot.svelte's spring from rt.squashAt/impactVy. +const BOUNCE_MIN_VY = 250 // px/s impact below which there's no bounce +const BOUNCE_RESTITUTION = 0.34 +const BOUNCE_MAX = 1 +// Landing skid: real sideways momentum survives touchdown as a short +// friction slide instead of the old dead stop. +const SKID_MIN_VX = 140 // px/s — slower sideways landings just stop +const SKID_ENTRY_MAX = 420 // px/s — cap on carried-in skid speed +const SKID_KEEP = 0.5 // fraction of touchdown vx kept as skid +const SKID_FRICTION = 900 // px/s^2 +// Wall ricochet: hard sideways throws bounce off the surface's side +// bounds mid-fall; a gentle drift still just stops at the margin. +const WALL_BOUNCE_MIN_VX = 150 // px/s +const WALL_BOUNCE_RESTITUTION = 0.45 +// Hop: a small autonomous forward hop (chickens hop!) — real projectile +// motion, no flapping. If the ground drops out mid-hop (hopped off a +// window edge), stepMascot hands off to a real fall. +const HOP_VY = 210 // px/s takeoff speed +const HOP_VX = 70 // px/s forward speed +const HOP_BACKSTOP_MS = 900 // behaviorUntil backstop; real exit is on landing + +// Ground tracking: Mascot.svelte's tick() chases the ground line (the top +// of whatever window/surface is beneath the mascot) each frame. A small +// per-tick change (a window being dragged smoothly, with the mascot riding +// along) follows instantly; anything the ground drops away by more than +// this is treated as the surface disappearing — falling takes over instead +// of snapping. See GROUND_FOLLOW_MAX_STEP/GROUND_DROP_FALL_PX in Mascot.svelte. + +// Default durations (ms) for self-selecting behaviors. Each BehaviorDef +// can override with its own minMs/maxMs. +const IDLE_MS = [1500, 4000] as const +const WANDER_MS = [2500, 5000] as const +const PECK_MS = [1200, 2200] as const +const SLEEP_MS = [6000, 12000] as const +const LAND_MS = 400 +const REACT_DEFAULT_MS = 1800 + +// Idle chatter: very occasional, unprompted, purely cosmetic one-liners — +// no signal value, just personality. Rolled once each time `idle` is +// (re-)entered, gated by both a probability and a cooldown so it stays +// rare rather than firing on every idle cycle (idle gets re-picked often +// by the weighted-random selector). See the `idle` BehaviorDef below. +const IDLE_CHATTER_CHANCE = 0.12 +const IDLE_CHATTER_COOLDOWN_MS = 25_000 +const IDLE_CHATTER_DURATION_MS = 2200 +const IDLE_CHATTER_LINES = [ + '🐔 Bawk.', + "💭 Wonder what's up on hubris…", + '🌾 Any seeds around?', + '😌 Nice day for uptime.', + '📦 So many containers…', + '☁️ Backup time yet?', + '🥚 Remember when I was an egg?', + '🔧 *pecks at nothing in particular*', + '🐧 Penguins are cool too, I guess.' +] +// Module-level (not per-runtime) since there's only ever one mascot — +// matches stimuli.ts's own module-level cooldown tracking. +let lastChatterAt = 0 + +// ─── BehaviorDef ───────────────────────────────────────────────────────── + +export interface BehaviorDef { + id: BehaviorId + /** Animation to play while this behavior is active. May depend on runtime state (e.g. facing). */ + anim: (rt: MascotRuntime, model: MascotModel) => AnimName + /** Called once when the behavior is entered (set up velocity, etc). */ + enter?: (rt: MascotRuntime) => void + /** Per-frame physics/integration. `dt` is already clamped to <= 100ms by the loop. */ + tick: (rt: MascotRuntime, dt: number, now: number) => void + /** Called when behaviorUntil has passed; returns the next behavior id, or null to trigger idle selection. */ + next: (rt: MascotRuntime, now: number) => BehaviorId | null + /** Idle-selection weight (>0 = eligible). Undefined/0 = never auto-picked. */ + weight?: number + /** Duration range in ms for this behavior when auto-selected. */ + minMs: number + maxMs: number +} + +function randRange(min: number, max: number): number { + return min + Math.random() * (max - min) +} + +function pickWeighted(candidates: BehaviorDef[]): BehaviorDef { + const total = candidates.reduce((s, b) => s + (b.weight ?? 0), 0) + let r = Math.random() * total + for (const b of candidates) { + r -= b.weight ?? 0 + if (r <= 0) return b + } + return candidates[0] +} + +// ─── ground / bounds helpers ───────────────────────────────────────────── + +/** + * (Re)start a flap cycle: records `now` as the last wing-beat and picks + * the next cycle length from the CURRENT descent speed — a fast fall + * (hard toss) flaps frantically, a gentle flutter flaps lazily — plus + * ±15% jitter so the rhythm never sounds like a metronome. + */ +function scheduleFlap(rt: MascotRuntime, now: number): void { + rt.fallPhaseAt = now + const speedFactor = Math.max(0, Math.min(1, rt.vy / TERMINAL_VY)) + const base = FLAP_CYCLE_MAX_MS - speedFactor * (FLAP_CYCLE_MAX_MS - FLAP_CYCLE_MIN_MS) + rt.flapCycleMs = base * (0.85 + Math.random() * 0.3) +} + +function groundY(rt: MascotRuntime): number { + // rt.groundY is recomputed each tick by Mascot.svelte from the window + // state — it's the top edge of the highest window beneath the mascot, + // or rt.bounds.h (surface bottom) when no window is beneath. + return rt.groundY +} + +function clampX(rt: MascotRuntime): void { + const minX = MARGIN / 2 + const maxX = rt.bounds.w - MARGIN / 2 + if (rt.x < minX) { + rt.x = minX + rt.facing = 1 + } + if (rt.x > maxX) { + rt.x = maxX + rt.facing = -1 + } +} + +// ─── BEHAVIORS registry ─────────────────────────────────────────────────── + +export const BEHAVIORS: Record = { + egg: { + id: 'egg', + anim: () => 'egg-idle', + tick: () => { + // Egg doesn't move on its own. + }, + next: () => 'egg', + minMs: 0, + maxMs: 0 + }, + + idle: { + id: 'idle', + // Periodic blink: enter() sets blinkUntil to the START of the next + // blink window (2–6s away). anim() returns 'blink' when we're past + // that start but within 150ms of it. A chatter bubble (see enter() + // below) takes over the sprite for as long as it's showing — the + // mouth-flap 'talk' loop reads as the mascot actually saying the line + // instead of just standing there while text happens to appear above it. + anim: (rt) => { + if (rt.bubbleText) return 'talk' + const now = performance.now() + if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink' + return 'idle' + }, + enter: (rt) => { + rt.blinkUntil = performance.now() + 2000 + Math.random() * 4000 + // Idle chatter: rare, cosmetic-only bubble line (see the constants + // above). Doesn't touch the FSM/behavior at all, just the bubble. + const now = performance.now() + if (now - lastChatterAt > IDLE_CHATTER_COOLDOWN_MS && Math.random() < IDLE_CHATTER_CHANCE) { + lastChatterAt = now + rt.bubbleText = IDLE_CHATTER_LINES[Math.floor(Math.random() * IDLE_CHATTER_LINES.length)] + rt.bubbleUntil = now + IDLE_CHATTER_DURATION_MS + } + }, + tick: () => { + // Standing still. + }, + next: () => null, + weight: 3, + minMs: IDLE_MS[0], + maxMs: IDLE_MS[1] + }, + + wander: { + id: 'wander', + anim: () => 'walk', + enter: (rt) => { + rt.vx = rt.facing * WALK_SPEED + }, + tick: (rt) => { + rt.x += rt.vx * (1 / 60) // dt is in seconds via the loop below; but tick receives ms — see stepMascot + // Actually the loop calls tick with dt in seconds; but to keep the + // BehaviorDef.tick signature consistent with the plan's `(rt, dt, now)` + // where dt is seconds-clamped, we'll re-derive below. The wander + // integration is redone in stepMascot to use dt correctly. + }, + next: () => null, + weight: 4, + minMs: WANDER_MS[0], + maxMs: WANDER_MS[1] + }, + + peck: { + id: 'peck', + anim: () => 'peck', + tick: () => { + // Stationary peck animation. + }, + next: () => null, + weight: 2, + minMs: PECK_MS[0], + maxMs: PECK_MS[1] + }, + + hop: { + id: 'hop', + // A little forward hop (chickens hop!). Real projectile motion — + // enter() throws it up-and-forward and stepMascot's 'hop' case + // integrates gravity until touchdown, which forces idle with a small + // landing squash (impactVy ≈ HOP_VY: light, no poof). + anim: () => 'flap', + enter: (rt) => { + rt.vy = -HOP_VY + rt.vx = rt.facing * HOP_VX + }, + tick: () => { + // Integration happens in stepMascot (needs dt in seconds). + }, + next: () => null, + weight: 2, + minMs: HOP_BACKSTOP_MS, + maxMs: HOP_BACKSTOP_MS * 2 + }, + + sleep: { + id: 'sleep', + anim: () => 'sleep', + tick: () => { + // Asleep. + }, + next: () => null, + weight: 1, + minMs: SLEEP_MS[0], + maxMs: SLEEP_MS[1] + }, + + dragged: { + id: 'dragged', + anim: () => 'dragged', + tick: () => { + // Position is owned by the pointer handler; nothing to do here. + }, + next: () => null, // exited only via forceBehavior from pointerup + minMs: 0, + maxMs: 0 + }, + + falling: { + id: 'falling', + // Flap briefly right after each wing-beat impulse (see stepMascot), + // glide the rest of the cycle. + anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'), + enter: (rt) => { + rt.bounceCount = 0 + scheduleFlap(rt, performance.now()) + // vx/vy are deliberately NOT reset here — they carry over from + // drag-release toss momentum (set by Mascot.svelte's onPointerUp) + // when falling starts from a throw, or stay at 0 when it starts from + // walking off an edge / a surface disappearing underfoot. + }, + tick: () => { + // Integration happens in stepMascot (needs dt in seconds). + }, + next: () => null, // exited via stepMascot when y reaches ground + minMs: 0, + maxMs: 0 + }, + + land: { + id: 'land', + anim: () => 'land', + tick: () => { + // Brief squash animation. + }, + // Routes to 'peck' instead of 'idle' when the drag that led here was + // released on top of a desktop icon (Mascot.svelte's onPointerUp sets + // investigateOnLand) — a little "investigate" reaction, whether the + // landing was immediate or came after a fall. Consumed once. + next: (rt) => { + if (rt.investigateOnLand) { + rt.investigateOnLand = false + return 'peck' + } + return 'idle' + }, + minMs: LAND_MS, + maxMs: LAND_MS + }, + + react: { + id: 'react', + anim: (rt) => rt.reactAnim ?? 'idle', + tick: () => { + // Reaction plays its animation; no motion. + }, + next: () => 'idle', + minMs: REACT_DEFAULT_MS, + maxMs: REACT_DEFAULT_MS + }, + + // Continuous engagement with the focused task window's active turn — not + // a timed pulse like `react` above. Entered/exited directly by + // MascotLayer's busy-state callback (see stimuli.ts's attachStimuli, + // second callback), which also keeps rt.busyTalking current every time + // the phase flips. No `weight` — never auto-picked by the idle selector, + // same as dragged/falling/land. + busy: { + id: 'busy', + anim: (rt) => (rt.busyTalking ? 'talk' : 'react-think'), + tick: () => { + // Stationary — just displays whichever sprite busyTalking selects. + }, + // Only reached if something calls next() on it directly, which nothing + // does in practice: MascotLayer forces 'idle' itself the moment + // stimuli.ts reports the turn ended. Falling back to 'idle' here is + // just a safe default, not the real exit path. + next: () => 'idle', + minMs: 0, + maxMs: 0 + } +} + +// ─── stepMascot: the per-frame driver ──────────────────────────────────── + +/** Force a behavior. Used by pointer code (dragged), gravity (falling), stimuli (react). */ +export function forceBehavior( + rt: MascotRuntime, + id: BehaviorId, + opts?: { anim?: AnimName; durationMs?: number } +): void { + rt.behavior = id + if (opts?.anim) { + if (id === 'react') rt.reactAnim = opts.anim + else { + // For non-react behaviors, override the anim by setting animStart on a custom anim. + rt.anim = opts.anim + rt.animStart = performance.now() + } + } + if (id === 'react' && opts?.anim) { + rt.anim = opts.anim + rt.animStart = performance.now() + } + const def = BEHAVIORS[id] + if (opts?.durationMs) { + rt.behaviorUntil = performance.now() + opts.durationMs + } else if (def.maxMs > 0) { + rt.behaviorUntil = performance.now() + randRange(def.minMs, def.maxMs) + } else { + rt.behaviorUntil = Number.POSITIVE_INFINITY + } + def.enter?.(rt) +} + +/** Step the FSM by `dt` ms (already clamped by the loop to <= 100ms). */ +export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void { + const dts = dt / 1000 + const def = BEHAVIORS[rt.behavior] + + // Per-behavior physics integration. Done here (not in def.tick) so the + // dt semantics stay consistent — the BehaviorDef.tick is reserved for + // any bespoke per-frame logic a future behavior needs. + switch (rt.behavior) { + case 'wander': { + rt.x += rt.vx * dts + // Flip at margins. + if (rt.x < MARGIN / 2) { + rt.x = MARGIN / 2 + rt.facing = 1 + rt.vx = WALK_SPEED + } else if (rt.x > rt.bounds.w - MARGIN / 2) { + rt.x = rt.bounds.w - MARGIN / 2 + rt.facing = -1 + rt.vx = -WALK_SPEED + } + // If the mascot walks off a window edge (ground dropped below + // current y), switch to falling — it flutters down to the next + // surface beneath (another window, or the desktop bottom). + if (rt.y < groundY(rt) - 1) { + forceBehavior(rt, 'falling') + } + break + } + case 'idle': { + // Same edge-detection as wander: a window can close/move under the + // mascot while it's idling, dropping the ground out from under it. + if (rt.y < groundY(rt) - 1) { + forceBehavior(rt, 'falling') + } + break + } + case 'falling': { + // Wing-beat: every flapCycleMs (dynamic — see scheduleFlap), cut + // the descent speed sharply: a real (if losing) attempt at flight + // rather than a flat drop. + if (now - rt.fallPhaseAt >= rt.flapCycleMs) { + rt.vy = Math.max(FLAP_MAX_LIFT, rt.vy - FLAP_IMPULSE) + scheduleFlap(rt, now) + } + rt.vy += GRAVITY * dts + // Soft terminal: a fall moving faster than terminal (a hard + // downward toss) decays back toward it under drag instead of being + // hard-clamped mid-air. + if (rt.vy > TERMINAL_VY) { + rt.vy = Math.max(TERMINAL_VY, rt.vy - SUPER_TERMINAL_DRAG * dts) + } + // Toss/drift horizontal velocity decays so it doesn't carry forever, + // plus a gentle sideways wobble so even a straight-down drop isn't + // perfectly vertical. + rt.vx *= Math.max(0, 1 - VX_DECAY_PER_S * dts) + const wobble = Math.sin(now / 260) * WOBBLE_VX + rt.x += (rt.vx + wobble) * dts + rt.y += rt.vy * dts + // Face the direction of travel on real sideways tosses. + if (Math.abs(rt.vx) > 40) rt.facing = rt.vx > 0 ? 1 : -1 + // Ricochet off the surface's side bounds on hard sideways throws + // (a gentle drift still just stops at the margin, via clampX). + const minX = MARGIN / 2 + const maxX = rt.bounds.w - MARGIN / 2 + if (rt.x <= minX && rt.vx < -WALL_BOUNCE_MIN_VX) { + rt.x = minX + rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION + } else if (rt.x >= maxX && rt.vx > WALL_BOUNCE_MIN_VX) { + rt.x = maxX + rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION + } + const gy = groundY(rt) + // Touchdown only while actually descending (vy > 0): a flap + // impulse or a post-bounce rise can briefly carry it upward at/below + // the ground line (or a window rising underneath can catch up to + // it) — those must not read as impacts. + if (rt.y >= gy && rt.vy > 0) { + rt.y = gy + const impact = rt.vy + if (impact >= BOUNCE_MIN_VY && rt.bounceCount < BOUNCE_MAX) { + // Hard impact: one soft, diminished bounce. The contact squash + // renders from squashAt/impactVy in Mascot.svelte; vx keeps + // decaying in the air for the second descent. + rt.bounceCount++ + rt.vy = -impact * BOUNCE_RESTITUTION + rt.impactVy = impact * 0.8 + rt.squashAt = now + } else { + rt.vy = 0 + rt.impactVy = impact + rt.squashAt = now + // Carry real sideways momentum into a short friction skid + // instead of the old dead stop. + const av = Math.abs(rt.vx) + rt.vx = + av >= SKID_MIN_VX ? Math.sign(rt.vx) * Math.min(av, SKID_ENTRY_MAX) * SKID_KEEP : 0 + forceBehavior(rt, 'land', { durationMs: LAND_MS }) + } + } + break + } + case 'hop': { + // A small forward hop — plain projectile integration, no flapping + // (too short). If the ground drops out mid-hop (it hopped off a + // window edge), hand off to a real fall. + rt.vy += GRAVITY * dts + rt.x += rt.vx * dts + rt.y += rt.vy * dts + const gy = groundY(rt) + if (rt.vy > 0 && gy - rt.y > 48) { + forceBehavior(rt, 'falling') + } else if (rt.y >= gy && rt.vy > 0) { + rt.y = gy + rt.impactVy = rt.vy + rt.squashAt = now + rt.vy = 0 + rt.vx = 0 + forceBehavior(rt, 'idle') + } + break + } + case 'land': { + // Skid: leftover horizontal momentum from a sideways touchdown + // (set in the falling→land transition above) decays under friction. + if (rt.vx !== 0) { + rt.x += rt.vx * dts + const dec = SKID_FRICTION * dts + rt.vx = Math.abs(rt.vx) <= dec ? 0 : rt.vx - Math.sign(rt.vx) * dec + } + break + } + case 'dragged': { + // Position owned by pointer; just keep y clamped above ground so + // release-from-ground doesn't immediately enter falling. + break + } + default: + break + } + + // Keep x in bounds for any behavior (defensive). + if (rt.behavior !== 'dragged') clampX(rt) + + // Sync anim from the active behavior (unless it was overridden by a + // react/dragged force; reactAnim holds the override for `react`). + if (rt.behavior === 'react' && rt.reactAnim) { + rt.anim = rt.reactAnim + } else { + const a = def.anim(rt, model) + if (a !== rt.anim) { + rt.anim = a + rt.animStart = now + } + } + + // Transition: only self-expiring behaviors (next() consult). dragged, + // falling, and hop manage their own exits (pointerup / touchdown). + if (rt.behavior === 'dragged' || rt.behavior === 'falling' || rt.behavior === 'hop') return + if (now < rt.behaviorUntil) return + + const next = def.next(rt, now) + if (next) { + forceBehavior(rt, next) + } else { + // Idle-select a new behavior via weighted random over eligible entries. + const eligible = (Object.values(BEHAVIORS) as BehaviorDef[]).filter((b) => (b.weight ?? 0) > 0) + if (eligible.length > 0) { + const picked = pickWeighted(eligible) + forceBehavior(rt, picked.id) + } + } +} + +/** Helper: is the mascot currently in an interruptible autonomous behavior (not dragged)? */ +export function isInterruptible(rt: MascotRuntime): boolean { + return rt.behavior !== 'dragged' +} + +/** Helper: is the mascot currently asleep (used by stimuli to check interruptsSleep)? */ +export function isAsleep(rt: MascotRuntime): boolean { + return rt.behavior === 'sleep' +} + +/** + * Release from a drag: land immediately if already at/below ground, or + * start falling otherwise. `rt.vx`/`rt.vy` are expected to already hold the + * release's toss velocity (set by Mascot.svelte's onPointerUp from recent + * pointer-move samples) — they're carried into `falling`, not reset here. + */ +export function releaseFromDrag(rt: MascotRuntime): void { + const gy = groundY(rt) + if (rt.y >= gy) { + rt.y = gy + rt.vy = 0 + rt.vx = 0 + rt.impactVy = 0 // set down gently — no squash spring, no feather poof + rt.squashAt = performance.now() + forceBehavior(rt, 'land', { durationMs: LAND_MS }) + } else { + forceBehavior(rt, 'falling') + } +} + +/** Recompute ground clamp on resize: if the mascot was at the old ground, snap to the new ground. */ +export function reground(rt: MascotRuntime, oldH: number): void { + const gy = groundY(rt) + if (rt.y >= oldH - 1) { + rt.y = gy + rt.vy = 0 + } else if (rt.y > gy) { + rt.y = gy + rt.vy = 0 + } + if (rt.behavior !== 'dragged') clampX(rt) +} diff --git a/web/src/lib/mascot/render.ts b/web/src/lib/mascot/render.ts new file mode 100644 index 0000000..69a2f51 --- /dev/null +++ b/web/src/lib/mascot/render.ts @@ -0,0 +1,61 @@ +// Stateless canvas painter for the mascot. Single render path: slice a +// 16x16 frame from a PNG sheet and draw it bottom-anchored, horizontally +// centered, optionally flipped (for left-facing) and optionally scaled +// (chick is smaller). Egg-stage sheets are also 16x16 PNGs (from the +// Onocentaur egg pack), so no special-case vector path is needed. +// +// The renderer is generic over the SPRITES registry — adding a new +// sheet to sprites.ts requires no change here. + +import type { AnimDef, MascotStage } from './types' +import { getImage } from './sprites' + +/** + * Logical canvas size (CSS px) the mascot is drawn onto. The sprite's + * feet land on the bottom row; the extra height above it (20x28, not + * 20x20) leaves a little headroom before the name label/reaction bubble + * (both real HTML elements floating above the canvas — see Mascot.svelte's + * template) start overlapping the sprite itself. + */ +export const CANVAS_W = 20 +export const CANVAS_H = 28 +/** Source frame size for the bundled sheets (px). */ +const FRAME = 16 + +export interface DrawOpts { + /** Render scale; usually STAGE_SCALE[stage]. */ + scale: number + /** Horizontal facing — when -1, draw the sheet mirrored. */ + facing: 1 | -1 + /** Wiggle phase (radians) for the egg wobble; ignored for chicken stages. 0 disables. */ + wiggle: number +} + +/** Draw one animation frame into the given 2D context (which is already sized CANVAS_W x CANVAS_H in CSS px). */ +export function drawFrame( + ctx: CanvasRenderingContext2D, + _stage: MascotStage, + anim: AnimDef, + frameIdx: number, + opts: DrawOpts +): void { + ctx.clearRect(0, 0, CANVAS_W, CANVAS_H) + const img = anim.src ? getImage(anim.src) : null + if (!img) return // not yet loaded — skip; the loop picks it up next tick + const idx = Math.max(0, Math.min(frameIdx, anim.frames - 1)) + const sx = idx * FRAME + const scale = opts.scale + const drawW = FRAME * scale + const drawH = FRAME * scale + // Bottom-anchor the 16x16 frame in the 20x20 canvas, then scale. + const dx = (CANVAS_W - drawW) / 2 + Math.sin(opts.wiggle) * 1.2 + const dy = CANVAS_H - drawH + ctx.save() + if (opts.facing === -1) { + ctx.translate(CANVAS_W, 0) + ctx.scale(-1, 1) + } + ctx.imageSmoothingEnabled = false + ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH) + ctx.restore() +} diff --git a/web/src/lib/mascot/sprites.ts b/web/src/lib/mascot/sprites.ts new file mode 100644 index 0000000..a2236e9 --- /dev/null +++ b/web/src/lib/mascot/sprites.ts @@ -0,0 +1,143 @@ +// Sprite registry. To add a new animation: +// 1. Add its name to `AnimName` in types.ts. +// 2. Add an entry under SPRITES[stage] here pointing at a 16x16-frame PNG sheet in /mascot/. +// 3. (Optional) Reference it from a behavior in behavior.ts or a reaction in stimuli.ts. +// `resolveAnim()` falls back to the stage's `idle` and finally a 1-frame +// placeholder, so a missing animation never crashes the renderer. +// +// Sheets are bundled at web/public/mascot/*.png (CC0, see +// web/public/mascot/LICENSE.txt). Each sheet is a horizontal strip of +// 16x16 px frames; the renderer slices frame `i` at x = i*16. +// +// Egg-stage animations come from the Onocentaur egg pack (single-frame +// 16x16 PNGs): an idle egg and shell halves (shown briefly at the hatch +// moment). The egg → chick transition fires on first naming (see +// state.svelte.ts), not on a timed incubation, so there's no progressive +// crack animation — the egg sits on egg-idle until the name dialog is +// submitted, then swaps to the chick. The egg-crack sheet is kept in the +// registry for future use but isn't selected by any behavior today. + +import type { AnimDef, AnimName, MascotStage } from './types' + +const EGG_IDLE: AnimDef = { src: '/mascot/egg-idle.png', frames: 1, fps: 1, loop: true } +const EGG_SHELL: AnimDef = { src: '/mascot/egg-shell.png', frames: 1, fps: 1, loop: true } + +export const SPRITES: Record>> = { + egg: { + 'egg-idle': EGG_IDLE, + 'egg-wiggle': EGG_IDLE, // wiggle is applied as a render-time transform; no separate frame + hatch: EGG_SHELL, + dragged: EGG_IDLE, + 'fall-flutter': EGG_IDLE, + land: EGG_IDLE + }, + // Chick and adult share sheets; only the render scale differs. + chick: { + idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true }, + blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true }, + walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true }, + peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true }, + flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, + sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true }, + talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true }, + dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, + 'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true }, + land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false }, + 'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true }, + 'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }, + 'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true }, + 'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true } + }, + adult: { + idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true }, + blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true }, + walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true }, + peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true }, + flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, + sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true }, + talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true }, + dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, + 'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true }, + land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false }, + 'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true }, + 'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }, + 'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true }, + 'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true } + } +} + +// Render scale per stage. The asset pack has one chicken size; the chick +// and adult both render at full scale (1.0) — downscaling to 0.75 for the +// chick looked blurry on high-DPI displays. The stage is conveyed by the +// tamagotchi model + behavior, not by sprite size. +export const STAGE_SCALE: Record = { + egg: 1, + chick: 1, + adult: 1 +} + +const PLACEHOLDER: AnimDef = { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true } + +/** Resolve an animation for a stage, falling back to the stage's idle, then a placeholder. */ +export function resolveAnim(stage: MascotStage, name: AnimName): AnimDef { + const set = SPRITES[stage] + const direct = set[name] + if (direct) return direct + if (name !== 'idle') { + const idle = set.idle + if (idle) return idle + } + return PLACEHOLDER +} + +/** Pick which frame of an AnimDef to draw at time `now` (ms). */ +export function frameIndex(anim: AnimDef, now: number, animStart: number): number { + const elapsed = now - animStart + if (anim.frames <= 1) return 0 + const idx = Math.floor((elapsed / 1000) * anim.fps) + if (anim.loop) return ((idx % anim.frames) + anim.frames) % anim.frames + return Math.min(idx, anim.frames - 1) +} + +// ─── Image cache / loader ──────────────────────────────────────────────── +// PNG sheets are loaded once into HTMLImageElement instances and reused. +// `loadSprites()` is called from Mascot.svelte on mount; `getImage()` +// returns the cached element (or null if not yet loaded, in which case +// the renderer just skips that frame — the loop will pick it up next +// tick once the image arrives). + +const imageCache = new Map() + +function loadOne(src: string): Promise { + const existing = imageCache.get(src) + if (existing && existing.complete) return Promise.resolve(existing) + return new Promise((resolve, reject) => { + const img = new Image() + img.src = src + img.onload = () => { + imageCache.set(src, img) + resolve(img) + } + img.onerror = () => reject(new Error(`mascot: failed to load ${src}`)) + }) +} + +/** Preload every sheet referenced by SPRITES for the given stages (default: all). */ +export async function loadSprites( + stages: MascotStage[] = ['egg', 'chick', 'adult'] +): Promise { + const srcs = new Set() + for (const stage of stages) { + for (const anim of Object.values(SPRITES[stage])) { + if (anim && anim.src) srcs.add(anim.src) + } + } + await Promise.all([...srcs].map(loadOne)) +} + +/** Get a cached sheet image, or null if not yet loaded. */ +export function getImage(src: string): HTMLImageElement | null { + const img = imageCache.get(src) + if (!img || !img.complete) return null + return img +} diff --git a/web/src/lib/mascot/state.svelte.ts b/web/src/lib/mascot/state.svelte.ts new file mode 100644 index 0000000..04ca408 --- /dev/null +++ b/web/src/lib/mascot/state.svelte.ts @@ -0,0 +1,208 @@ +// Tamagotchi model: long-lived, persisted, slow-moving state (separate +// from the per-frame MascotRuntime in behavior.ts). Backed by a runes +// `$state` at module scope, mutators exported as functions, debounced +// localStorage persistence mirroring stores/windows.ts' 300ms cadence. +// +// Persistence schema lives at localStorage['oikos-mascot'] and is +// versioned via the `version` field; `migrate(raw)` is the stub where +// future schema changes go (v1 has no migrations to perform). +// +// Multi-tab races (two tabs both writing 'oikos-mascot') are +// last-writer-wins — accepted for v1, not solved. A future pass could +// listen to the `storage` event if it becomes a real problem. + +import type { MascotStage } from './types' + +const STORAGE_KEY = 'oikos-mascot' +const PERSIST_DEBOUNCE_MS = 300 + +export interface MascotModel { + version: 1 + stage: MascotStage + name: string | null + /** Binary egg-hatch flag: 0 until first naming, 1 after. The egg → chick transition fires on naming, not on a timer. */ + hatchProgress: number + /** 0..100, slow decay, boosted by pet/feed. */ + happiness: number + /** Chick -> adult growth hook; reactions like `eureka` grant xp. */ + xp: number + /** epoch ms when the egg hatched (chick/adult), null while still an egg. */ + hatchedAt: number | null + /** Persisted rest x position (surface-relative) so the mascot doesn't reset to center on reload. */ + lastPos: { x: number } | null + /** epoch ms of the last foreground tick — for capping passive decay. */ + lastSeen: number +} + +/** XP required to graduate from chick to adult. */ +export const ADULT_XP = 200 + +function defaultModel(): MascotModel { + return { + version: 1, + stage: 'egg', + name: null, + hatchProgress: 0, + happiness: 50, + xp: 0, + hatchedAt: null, + lastPos: null, + lastSeen: Date.now() + } +} + +// Module-scoped rune. Mutators below mutate this in place (Object.assign +// / direct property writes); Svelte's reactivity tracks deep property +// access in components that read it. `const` because the binding itself +// is never reassigned — only its properties are. +const model: MascotModel = $state(defaultModel()) + +// ─── load / migrate / persist ──────────────────────────────────────────── + +function migrate(raw: unknown): MascotModel { + // v1 has no migrations to perform; this stub documents where future + // version-gated schema changes go (switch on `raw.version`). + if (raw && typeof raw === 'object') { + const r = raw as Partial + if (r.version === 1) { + return { ...defaultModel(), ...r, version: 1 } as MascotModel + } + } + return defaultModel() +} + +function load(): MascotModel { + if (typeof localStorage === 'undefined') return defaultModel() + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return defaultModel() + try { + return migrate(JSON.parse(raw)) + } catch { + return defaultModel() + } +} + +let persistTimer: ReturnType | null = null + +function schedulePersist(): void { + if (typeof localStorage === 'undefined') return + if (persistTimer) clearTimeout(persistTimer) + persistTimer = setTimeout(() => { + persistTimer = null + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(model)) + } catch { + // quota / privacy mode — swallow; the model still lives in memory for this session + } + }, PERSIST_DEBOUNCE_MS) +} + +function flushPersist(): void { + if (persistTimer) { + clearTimeout(persistTimer) + persistTimer = null + } + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(model)) + } catch { + // ignore + } + } +} + +/** Initialize the module state from localStorage. Idempotent. Call once on app boot (or first mascot mount). */ +export function initMascotState(): void { + if (typeof localStorage === 'undefined') return + const loaded = load() + // Mutate the existing $state object in place — reassigning `model` to + // a new $state() isn't allowed outside the top level in runes mode. + Object.assign(model, loaded) + model.lastSeen = Date.now() + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', flushPersist) + } +} + +// ─── accessors / mutators ──────────────────────────────────────────────── + +export function getModel(): MascotModel { + return model +} + +export function setStage(stage: MascotStage): void { + model.stage = stage + if (stage !== 'egg' && model.hatchedAt === null) { + model.hatchedAt = Date.now() + } + if (stage === 'adult') { + model.xp = Math.max(model.xp, ADULT_XP) + } + schedulePersist() +} + +export function setName(name: string): void { + model.name = name.slice(0, 24) + schedulePersist() +} + +export function grantXp(n: number): void { + if (n === 0) return + model.xp = Math.max(0, model.xp + n) + schedulePersist() +} + +export function feed(): void { + model.happiness = Math.min(100, model.happiness + 8) + grantXp(2) +} + +export function pet(): void { + model.happiness = Math.min(100, model.happiness + 4) + grantXp(1) +} + +export function setLastPos(x: number): void { + model.lastPos = { x } + schedulePersist() +} + +export function resetModel(): void { + Object.assign(model, defaultModel()) + schedulePersist() +} + +/** + * Advance lifecycle state. Called ~1x/sec from Mascot.svelte's loop (NOT + * every frame). The egg → chick transition is NOT timed here — it fires + * once, on first naming (see MascotLayer's name-dialog submit handler, + * which calls forceHatch() after setName). This tick only handles slow + * passive happiness decay for hatched stages. + */ +export function tickLifecycle(dtMs: number): void { + const dtSec = dtMs / 1000 + if (model.stage === 'chick') { + // Slow passive happiness decay (1/sec) so the tamagotchi benefits from + // being interacted with; only meaningful while the model is alive. + model.happiness = Math.max(0, model.happiness - 0.05 * dtSec) + } + model.lastSeen = Date.now() + advanceStageIfReady() +} + +/** Promote egg -> chick when hatchProgress hits 1 (set by forceHatch on first naming), chick -> adult when xp hits ADULT_XP. */ +export function advanceStageIfReady(): void { + if (model.stage === 'egg' && model.hatchProgress >= 1) { + setStage('chick') + } else if (model.stage === 'chick' && model.xp >= ADULT_XP) { + setStage('adult') + } +} + +/** Hatches the egg immediately. Called from MascotLayer's name-dialog submit handler after the first naming, and from the Debug radial-menu action. */ +export function forceHatch(): void { + if (model.stage === 'egg') { + model.hatchProgress = 1 + setStage('chick') + } +} diff --git a/web/src/lib/mascot/stimuli.ts b/web/src/lib/mascot/stimuli.ts new file mode 100644 index 0000000..ecd661f --- /dev/null +++ b/web/src/lib/mascot/stimuli.ts @@ -0,0 +1,224 @@ +// Stimulus / reaction system. To add a new environment reaction: +// 1. Add a `ReactionDef` entry to REACTIONS below. +// 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block +// inside `attachStimuli()`'s per-session bundle. +// The dispatch logic (priority + cooldown + interruptsSleep) is generic +// over REACTIONS — no engine change is needed for a new reaction. +// +// Reactions are dispatched into the MascotLayer via the `emit` callback +// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim, +// durationMs}) and sets the bubble (optionally overridden per-dispatch — +// see the `bubbleOverride` param — so e.g. eureka can show the actual +// knowledge title instead of a generic line). `dragged` always wins over +// any reaction; `sleep` is broken only when `interruptsSleep` is true. +// +// Scoping: everything below tracks whichever task/chat window currently has +// focus (windows.ts's focusedSessionId), re-bound every time focus moves — +// the mascot reacts to the task the operator is actually looking at, not to +// every session fleet-wide. Nothing fires while no task window is focused. +// +// `thinking`/`talking` aren't pulse reactions at all: they're the +// continuous `busy` FSM behavior (behavior.ts), driven by the second +// `setBusy` callback rather than `emit` — see the "Busy" block below. + +import { derived } from 'svelte/store' +import { chatFor } from '$lib/stores/chat' +import { workspaceFor } from '$lib/stores/workspace' +import { activityLogFor } from '$lib/stores/activity' +import { focusedSessionId } from '$lib/stores/windows' +import { grantXp } from './state.svelte' +import type { AnimName } from './types' + +export interface ReactionDef { + id: string + /** Animation to play while this reaction is active. */ + anim: AnimName + /** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). Per-dispatch callers may override this — see `tryDispatch`'s bubbleOverride param. */ + bubble?: string + /** Higher priority interrupts lower-priority reactions. */ + priority: number + /** Minimum ms between dispatches of this same reaction. */ + cooldownMs: number + /** How long the reaction animation plays (ms). */ + durationMs: number + /** When true, breaks the mascot out of `sleep` to play the reaction. */ + interruptsSleep?: boolean + /** Side effect to run on dispatch (e.g. grantXp(5) on eureka). */ + effect?: () => void +} + +export const REACTIONS: Record = { + thinking: { + id: 'thinking', + anim: 'react-think', + bubble: '💭 Thinking…', + priority: 1, + cooldownMs: 0, + durationMs: 4000, + interruptsSleep: false + }, + eureka: { + id: 'eureka', + anim: 'react-eureka', + bubble: '💡 Eureka!', + priority: 2, + cooldownMs: 3000, + durationMs: 2200, + interruptsSleep: false, + effect: () => grantXp(5) + }, + alarmed: { + id: 'alarmed', + anim: 'react-alarm', + bubble: '❗ Need your OK', + priority: 3, + cooldownMs: 3000, + durationMs: 2500, + interruptsSleep: true + }, + happy: { + id: 'happy', + anim: 'react-happy', + bubble: '🎉 Nice work!', + priority: 1, + cooldownMs: 3000, + durationMs: 2000 + } +} + +/** Per-reaction last-dispatched timestamp (ms). */ +const lastFired = new Map() + +/** Tracks the current reaction's priority so a lower-priority one can't interrupt a higher one mid-flight. */ +let currentReactPriority = 0 +let currentReactUntil = 0 + +const BUBBLE_MAX = 70 + +/** Truncate a dynamic bubble line (a knowledge title, a task summary) to something that still fits the speech bubble. */ +function truncate(s: string, max = BUBBLE_MAX): string { + return s.length > max ? `${s.slice(0, max - 1)}…` : s +} + +/** + * Attach all stimulus subscriptions. Returns a teardown that detaches + * everything. `emit` is the MascotLayer's bridge for pulse reactions + * (eureka/alarmed/happy — priority+cooldown gated, see tryDispatch); + * `setBusy` is its bridge for the continuous thinking/talking state (no + * gating — it's a live phase, not a discrete event). + */ +export function attachStimuli( + emit: (r: ReactionDef, bubbleOverride?: string) => void, + setBusy: (phase: 'thinking' | 'talking' | null) => void +): () => void { + const unsubs: Array<() => void> = [] + + // Everything below is re-bound every time focus moves to a different + // task window (or away from one entirely) — teardownSession tears down + // the previous session's bundle before (re)building for the new one. + let teardownSession: (() => void) | null = null + unsubs.push( + focusedSessionId.subscribe((sid) => { + teardownSession?.() + teardownSession = null + setBusy(null) + if (!sid) return + + const inner: Array<() => void> = [] + const chat = chatFor(sid) + const ws = workspaceFor(sid) + const log = activityLogFor(sid) + + // ─── Busy: thinking (composing, no text yet) vs talking (text is + // streaming out) — see the `busy` BehaviorDef in behavior.ts. ────── + inner.push( + derived([chat.streaming, chat.messages], ([s, msgs]) => { + if (!s) return null + const last = msgs[msgs.length - 1] + return last?.role === 'assistant' && last.text.length > 0 ? 'talking' : 'thinking' + }).subscribe(setBusy) + ) + + // ─── Eureka (new knowledge) / Happy (task completed successfully) — + // both derived from the session's own activity log. The store + // recomputes wholesale on every emission (not append-only), so new + // entries are detected by diffing ids against the last-seen set. + // The first emission after (re)subscribing is never replayed as + // reactions — switching focus to an already-in-progress or already- + // done task shouldn't retroactively fire pulses for old entries. ── + let seenIds = new Set() + let firstLogEmission = true + inner.push( + log.subscribe((entries) => { + const nextIds = new Set() + for (const e of entries) { + nextIds.add(e.id) + if (firstLogEmission || seenIds.has(e.id)) continue + if (e.type === 'knowledge') { + tryDispatch( + REACTIONS.eureka, + emit, + `💡 ${truncate(e.description.replace(/^Recorded: /, ''))}` + ) + } else if (e.type === 'complete' && e.status !== 'failed') { + tryDispatch(REACTIONS.happy, emit, `🎉 ${truncate(e.description)}`) + } + } + seenIds = nextIds + firstLogEmission = false + }) + ) + + // ─── Alarmed: a new operator question was raised (permission + // needed) — null -> non-null edge, same first-emission skip as + // above (focusing a task that already has a pending question + // shouldn't itself re-pulse the reaction). ────────────────────── + let hadQuestion = false + let firstQuestionEmission = true + inner.push( + ws.openQuestion.subscribe((q) => { + if (!firstQuestionEmission && q && !hadQuestion) { + tryDispatch(REACTIONS.alarmed, emit) + } + hadQuestion = q !== null + firstQuestionEmission = false + }) + ) + + teardownSession = () => { + for (const u of inner) u() + } + }) + ) + unsubs.push(() => teardownSession?.()) + + return () => { + for (const u of unsubs) u() + } +} + +/** Cooldown + priority gate before handing the reaction to MascotLayer. */ +function tryDispatch( + r: ReactionDef, + emit: (r: ReactionDef, bubbleOverride?: string) => void, + bubbleOverride?: string +): void { + const now = performance.now() + const last = lastFired.get(r.id) ?? 0 + if (r.cooldownMs > 0 && now - last < r.cooldownMs) return + // Priority: a new reaction must have priority >= the current one's, + // unless the current one has expired (now > currentReactUntil). + const currentExpired = now > currentReactUntil + if (!currentExpired && r.priority < currentReactPriority) return + lastFired.set(r.id, now) + currentReactPriority = r.priority + currentReactUntil = now + r.durationMs + emit(r, bubbleOverride) +} + +/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */ +export function resetStimuliState(): void { + lastFired.clear() + currentReactPriority = 0 + currentReactUntil = 0 +} diff --git a/web/src/lib/mascot/types.ts b/web/src/lib/mascot/types.ts new file mode 100644 index 0000000..826ef77 --- /dev/null +++ b/web/src/lib/mascot/types.ts @@ -0,0 +1,192 @@ +// Type definitions for the desktop mascot sprite/behavior/action/reaction +// system. Every registry below (sprites.ts, behavior.ts, actions.ts, +// stimuli.ts) is plain data over these types, so each can be extended +// independently without touching the engine code in Mascot.svelte. + +import type { Component } from 'svelte' + +/** Slow-moving lifecycle stage of the tamagotchi. Drives which sprite set is drawn and the render scale. */ +export type MascotStage = 'egg' | 'chick' | 'adult' + +/** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */ +export type AnimName = + | 'egg-idle' + | 'egg-wiggle' + | 'egg-crack' + | 'hatch' + | 'idle' + | 'blink' + | 'walk' + | 'peck' + | 'flap' + | 'sleep' + | 'talk' + | 'dragged' + | 'fall-flutter' + | 'land' + | 'react-eureka' + | 'react-alarm' + | 'react-think' + | 'react-happy' + +/** + * A sprite-sheet animation. The sheet is a horizontal strip of 16x16 px + * frames (PNG, RGBA) served from /mascot/*. The renderer slices frame + * `i` from x = i*16, y = 0, w = 16, h = 16. The whole mascot sprite + * canvas is 20x20 logical px (so feet land on a consistent ground line + * across stages); the 16x16 frame is bottom-anchored and horizontally + * centered inside it. + * + * Egg-stage animations are vector-drawn by render.ts (no PNG); their + * AnimDef entries still exist for the FSM to reference but their `src` + * is ignored. + */ +export interface AnimDef { + /** Sheet URL (resolved from /mascot/). Ignored for egg-vector anims. */ + src: string + /** Frame count in the sheet (sheet width = frames * 16). */ + frames: number + /** Frames per second. */ + fps: number + /** Whether to wrap the frame index once it reaches `frames`. */ + loop: boolean +} + +/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */ +export type BehaviorId = + | 'egg' + | 'idle' + | 'wander' + | 'peck' + | 'hop' + | 'sleep' + | 'dragged' + | 'falling' + | 'land' + | 'react' + | 'busy' + +/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */ +export type Stimulus = string + +/** Shape of a radial-menu action node. See actions.ts. */ +export interface RadialAction { + id: string + label: string + icon?: Component + /** + * Shown as a small muted second line under the label — mainly used by + * Debug entries to say what normally triggers the thing being forced + * (e.g. "Normally fires when Nomos starts streaming a reply"), so a + * manual test doesn't need to be cross-referenced against the code to + * know what it's simulating. + */ + description?: string + /** Visibility predicate (e.g. "Rename" only once hatched). Defaults to always visible. */ + visible?: (model: import('./state.svelte').MascotModel) => boolean + /** Sub-actions — selecting this node swaps the ring to its children + a back button. */ + children?: RadialAction[] + /** Leaf handler. Mutates model/runtime via the passed context. */ + action?: (ctx: MascotActionCtx) => void +} + +/** Argument passed to a RadialAction leaf handler. */ +export interface MascotActionCtx { + model: import('./state.svelte').MascotModel + runtime: MascotRuntime + /** Force a behavior (e.g. sleep). See behavior.ts. */ + force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) => void + /** Request the name dialog to open. */ + requestRename: () => void + /** Advance to the next lifecycle stage immediately (debug). */ + forceHatch: () => void + /** Force a specific lifecycle stage (debug). */ + forceStage: (stage: MascotStage) => void + /** Reset the tamagotchi model to defaults. */ + reset: () => void + /** Redraw the menu (after a visibility-affecting mutation). */ + refresh: () => void +} + +/** Frame-to-frame state of the mascot on the desktop surface. Owned by Mascot.svelte. */ +export interface MascotRuntime { + /** Sprite bottom-center, surface (not viewport) coords. */ + x: number + y: number + vx: number + vy: number + facing: 1 | -1 + behavior: BehaviorId + /** performance.now() ms after which the current behavior should transition (its next() is consulted). */ + behaviorUntil: number + /** Currently-playing animation. */ + anim: AnimName + /** performance.now() ms when the current animation started. */ + animStart: number + /** When behavior === 'react', the animation to play (overrides the behavior's default anim). */ + reactAnim: AnimName | null + /** Surface bounds (width/height in CSS pixels). Updated on resize. */ + bounds: { w: number; h: number } + /** + * Current ground line at the mascot's x: the top edge of the highest + * non-minimized window beneath it, or bounds.h (surface bottom) when + * no window is beneath. Updated each tick by Mascot.svelte from + * wmState; the FSM uses this as the ground for falling/landing. + */ + groundY: number + /** + * Optional text/emoji shown above the mascot in a real HTML speech + * bubble (Mascot.svelte's template), not a sprite — e.g. '❤️', '💡', or + * a short phrase. + */ + bubbleText: string | null + /** performance.now() ms until which bubbleText stays visible; the game loop (tick()) clears it once this passes. */ + bubbleUntil: number + /** performance.now() ms until which the idle behavior should play 'blink' instead of 'idle'. */ + blinkUntil: number + /** + * performance.now() ms marking the start of the current flap/glide + * sub-phase within a `falling` behavior — see flapCycleMs below and + * scheduleFlap() in behavior.ts. Reset each time `falling` is entered. + */ + fallPhaseAt: number + /** + * Current flap-cycle length (ms) while `falling`: the interval between + * wing-beat impulses. Dynamically shortened by descent speed (panic + * flapping) plus random jitter so the rhythm never reads metronomic. + */ + flapCycleMs: number + /** + * performance.now() ms of the last ground impact (a landing or a + * bounce contact). Drives the squash-and-stretch impact spring in + * Mascot.svelte and the feather-poof particle spawn. 0 = never landed. + */ + squashAt: number + /** + * Descent speed (px/s) at the moment of the last ground impact — + * scales the squash-spring depth and the feather-poof size. + * 0 = a gentle set-down (no squash, no poof). + */ + impactVy: number + /** + * Bounces so far in the current fall. Reset to 0 by `falling.enter()`; + * capped by BOUNCE_MAX in behavior.ts. + */ + bounceCount: number + /** + * Set (by Mascot.svelte's onPointerUp) when a drag is released on top of + * a desktop icon. Consumed once by the `land` BehaviorDef's `next()` — + * routes the next landing to `peck` instead of `idle`, whether that + * landing happens immediately (already on the ground) or after a fall. + * Always false outside that one moment. + */ + investigateOnLand: boolean + /** + * When behavior === 'busy': which phase of the focused task's active + * turn to render — true plays the 'talk' sprite (text is streaming + * out), false plays 'react-think' (computing, nothing written yet). + * Set directly by MascotLayer's busy-state callback (see stimuli.ts); + * read each tick by the `busy` BehaviorDef's anim() in behavior.ts. + */ + busyTalking: boolean +} diff --git a/web/src/lib/oidc.ts b/web/src/lib/oidc.ts new file mode 100644 index 0000000..4d11868 --- /dev/null +++ b/web/src/lib/oidc.ts @@ -0,0 +1,327 @@ +import { apiBase, getConfig } from './config' + +interface OIDCConfig { + issuer: string + client_id: string + authorization_endpoint: string +} + +interface TokenResponse { + access_token: string + token_type: string + expires_in?: number + refresh_token?: string + id_token?: string +} + +interface OIDCState { + config: OIDCConfig | null + accessToken: string | null + expiresAt: number | null // epoch ms when access_token expires, or null if unknown + refreshToken: string | null + user: string | null + refreshing: Promise | null +} + +const SESSION_KEY = 'oidc_access_token' +const EXPIRES_KEY = 'oidc_expires_at' +const REFRESH_KEY = 'oidc_refresh_token' +const USER_KEY = 'oidc_user' +const PKCE_KEY = 'oidc_pkce_verifier' +const STATE_KEY = 'oidc_state' + +// Skew margin: treat a token as expired this many ms before its real exp, +// so a refresh kicks in before a request races the wire and 401s. +const EXPIRY_SKEW_MS = 30_000 + +let state: OIDCState = { + config: null, + accessToken: sessionStorage.getItem(SESSION_KEY), + expiresAt: Number(sessionStorage.getItem(EXPIRES_KEY)) || null, + refreshToken: localStorage.getItem(REFRESH_KEY), + user: localStorage.getItem(USER_KEY), + refreshing: null +} + +async function fetchConfig(): Promise { + try { + const resp = await fetch(apiBase('/api/v1/auth/oidc-config')) + if (!resp.ok) return null + const cfg: OIDCConfig = await resp.json() + state.config = cfg + return cfg + } catch { + return null + } +} + +function base64URLEncode(buf: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +function generateRandom(len: number): string { + const arr = new Uint8Array(len) + crypto.getRandomValues(arr) + return base64URLEncode(arr) +} + +async function sha256(plain: string): Promise { + return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain)) +} + +export async function startLogin(): Promise { + const cfg = state.config ?? (await fetchConfig()) + if (!cfg) throw new Error('OIDC not configured on server') + + const codeVerifier = generateRandom(64) + const challengeBuf = await sha256(codeVerifier) + const codeChallenge = base64URLEncode(challengeBuf) + const oidcState = generateRandom(32) + + sessionStorage.setItem(PKCE_KEY, codeVerifier) + sessionStorage.setItem(STATE_KEY, oidcState) + + const isDesktop = new URLSearchParams(location.search).has('desktop') + + let redirectURI: string + let stateParam: string + + if (isDesktop) { + const c = getConfig() + redirectURI = (c.apiUrl || location.origin).replace(/\/$/, '') + '/oidc-callback' + stateParam = oidcState + '.' + codeVerifier + } else { + redirectURI = (location.origin + location.pathname).replace(/\/$/, '') + stateParam = oidcState + } + + const params = new URLSearchParams({ + response_type: 'code', + client_id: cfg.client_id, + redirect_uri: redirectURI, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state: stateParam, + scope: 'openid profile email' + }) + + if (isDesktop) { + const apiUrl = getConfig().apiUrl || '' + const ret = encodeURIComponent( + location.origin + + location.pathname.replace(/\/$/, '') + + '?desktop=1&apiUrl=' + + encodeURIComponent(apiUrl) + ) + location.href = `http://127.0.0.1:18901/oidc/start?apiUrl=${encodeURIComponent(apiUrl)}&ret=${ret}` + return + } + + location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}` +} + +export async function handleCallback(code: string, returnedState: string): Promise { + const verifier = sessionStorage.getItem(PKCE_KEY) + const savedState = sessionStorage.getItem(STATE_KEY) + sessionStorage.removeItem(PKCE_KEY) + sessionStorage.removeItem(STATE_KEY) + + if (!verifier || savedState !== returnedState) return false + + const cfg = state.config ?? (await fetchConfig()) + if (!cfg) return false + + const redirectURI = (location.origin + location.pathname).replace(/\/$/, '') + + try { + const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + code_verifier: verifier, + redirect_uri: redirectURI + }) + }) + + if (!resp.ok) return false + + const tokens: TokenResponse = await resp.json() + if (!tokens.access_token) return false + + storeTokens(tokens) + + if (tokens.id_token) { + const user = parseIDTokenUser(tokens.id_token) + if (user) { + state.user = user + localStorage.setItem(USER_KEY, user) + } + } + + return true + } catch { + return false + } +} + +function parseIDTokenUser(idToken: string): string | null { + try { + const payload = idToken.split('.')[1] + const claims = JSON.parse(atob(payload)) + return claims.preferred_username || claims.email || claims.sub || null + } catch { + return null + } +} + +function storeTokens(tokens: TokenResponse) { + state.accessToken = tokens.access_token + + // Track expiry so getToken()/ensureToken() can refresh proactively. Default + // to 5 min if the provider omits expires_in — a safe lower bound that keeps + // refresh on a sane cadence rather than treating the token as never-expiring. + const ttl = tokens.expires_in ?? 300 + state.expiresAt = Date.now() + ttl * 1000 + + sessionStorage.setItem(SESSION_KEY, tokens.access_token) + sessionStorage.setItem(EXPIRES_KEY, String(state.expiresAt)) + + if (tokens.refresh_token) { + state.refreshToken = tokens.refresh_token + localStorage.setItem(REFRESH_KEY, tokens.refresh_token) + } +} + +export function getToken(): string | null { + // Treat a token whose exp we never recorded (e.g. a pre-fix login) as + // usable once: if it's still valid server-side it'll pass; if not, the + // 401 handler in fetchWithAuth will flush it and trigger refresh. + if (state.expiresAt !== null && Date.now() >= state.expiresAt - EXPIRY_SKEW_MS) { + return null + } + return state.accessToken +} + +export function getUser(): string | null { + return state.user +} + +export function isOIDCAvailable(): boolean { + return !!(state.accessToken || state.refreshToken) +} + +export async function ensureToken(): Promise { + // getToken() returns null when the token is missing OR expired-but-present. + // Both cases should trigger a refresh if we have a refresh_token. + const tok = getToken() + if (tok) return tok + + if (state.refreshToken) { + return refreshAccessToken() + } + + return null +} + +async function refreshAccessToken(): Promise { + if (state.refreshing) return state.refreshing + + const cfg = state.config ?? (await fetchConfig()) + if (!cfg || !state.refreshToken) return null + + state.refreshing = (async () => { + try { + const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: state.refreshToken + }) + }) + + if (!resp.ok) { + clearTokens() + return null + } + + const tokens: TokenResponse = await resp.json() + if (!tokens.access_token) { + clearTokens() + return null + } + + storeTokens(tokens) + return tokens.access_token + } catch { + clearTokens() + return null + } finally { + state.refreshing = null + } + })() + + return state.refreshing +} + +function clearTokens() { + state.accessToken = null + state.expiresAt = null + state.refreshToken = null + state.user = null + sessionStorage.removeItem(SESSION_KEY) + sessionStorage.removeItem(EXPIRES_KEY) + localStorage.removeItem(REFRESH_KEY) + localStorage.removeItem(USER_KEY) +} + +export function logout(): void { + clearTokens() +} + +export function isOIDCConfigured(): boolean { + // A session counts as configured if there's a refresh_token (can recover an + // expired access_token) or a still-valid access_token. An expired access + // token with no refresh_token means we'd have to re-login, so don't claim + // OIDC is configured in that state — let the static token (if any) take over. + if (state.refreshToken) return true + return getToken() !== null +} + +export async function initOIDC(): Promise { + // Use ensureToken so an expired-but-present access_token (e.g. page reload + // mid-session) triggers a refresh instead of being returned as-is. + if (state.refreshToken || state.accessToken) { + const token = await ensureToken() + return token !== null + } + + return false +} + +export function hasPendingCallback(): boolean { + const params = new URLSearchParams(location.search) + return params.has('code') && params.has('state') +} + +export async function processPendingCallback(): Promise { + const params = new URLSearchParams(location.search) + const code = params.get('code') + const oidcState = params.get('state') + + if (!code || !oidcState) return false + + const ok = await handleCallback(code, oidcState) + + const url = new URL(location.href) + url.searchParams.delete('code') + url.searchParams.delete('state') + history.replaceState(null, '', url.toString()) + + return ok +} diff --git a/web/src/lib/shims/app-environment.ts b/web/src/lib/shims/app-environment.ts new file mode 100644 index 0000000..a5d1731 --- /dev/null +++ b/web/src/lib/shims/app-environment.ts @@ -0,0 +1,4 @@ +// Shim for SvelteKit's `$app/environment` module — this is a plain Vite app, +// not SvelteKit, but svelte-splitpanes imports `browser` from it for its +// browser-detection utility. Aliased in vite.config.ts. +export const browser = typeof window !== 'undefined' diff --git a/web/src/lib/stores/activity.test.ts b/web/src/lib/stores/activity.test.ts new file mode 100644 index 0000000..0ff8c7a --- /dev/null +++ b/web/src/lib/stores/activity.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi } from 'vitest' +import { writable } from 'svelte/store' + +// computeActivityLog is a pure derivation; it only needs TYPES from the store +// modules, so mock their runtime exports to keep the test isolated from the +// real store graph (workspace.ts, e.g., starts a top-level setInterval). +vi.mock('./chat', () => ({ + messages: writable([]), + currentSession: writable(null), + chatFor: vi.fn(() => ({ + messages: writable([]), + streaming: writable(false), + connectionState: writable('connected'), + error: writable(null), + notFound: writable(false) + })) +})) +vi.mock('./workspace', () => ({ + planSteps: writable([]), + currentTask: writable(null), + workspaceFor: vi.fn(() => ({})), + taskFor: vi.fn(() => writable(null)) +})) +vi.mock('./execstream', () => ({ + liveExecutionOutputFor: vi.fn(() => writable(null)) +})) + +import { computeActivityLog, toolResultSummary } from './activity' +import type { ChatMessage } from './chat' +import type { PlanStep, Session } from '$lib/api' + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +function msg(partial: Partial & { id: string }): ChatMessage { + return { + id: partial.id, + role: 'assistant', + text: '', + tools: partial.tools ?? [], + pendingApprovals: [], + created_at: partial.created_at + } +} + +function toolResult(name: string, id: string): NonNullable[number] { + return { type: 'tool_result', name, id, result: 'ok' } +} + +function toolUse( + name: string, + id: string, + args?: Record +): NonNullable[number] { + return { type: 'tool_use', name, id, args } +} + +describe('computeActivityLog timestamps (P0.2)', () => { + it('uses the message created_at for persisted tool calls, not a fabricated spread', () => { + const created = '2026-07-29T20:08:10Z' + const m = msg({ + id: 'm1', + created_at: created, + tools: [toolResult('get_entity', 't1'), toolResult('run', 't2')] + }) + const entries = computeActivityLog([m], [], null, new Map()) + const want = new Date(created).getTime() + for (const id of ['t1', 't2']) { + const e = entries.find((x) => x.id === id) + expect(e, `entry ${id} should exist`).toBeDefined() + expect(e!.timestamp).toBe(want) // real time, shared per message — no now-(len-i)*1000 + } + }) + + it('freezes live entries (no created_at) so re-derivation never churns them', async () => { + const m = msg({ id: 'm1', tools: [toolResult('run', 't1')] }) // no created_at + const frozen = new Map() + const a = computeActivityLog([m], [], null, frozen) + const ts1 = a.find((e) => e.id === 't1')!.timestamp + await sleep(60) // the 3s poller re-derives on a later tick + const b = computeActivityLog([m], [], null, frozen) + const ts2 = b.find((e) => e.id === 't1')!.timestamp + expect(ts2).toBe(ts1) // frozen — the previous bug marched every entry forward + }) + + it('is pure w.r.t. wall-clock: two calls with identical inputs give identical output', async () => { + const created = '2026-07-29T20:08:10Z' + const msgs = [ + msg({ id: 'm1', created_at: created, tools: [toolResult('get_entity', 't1')] }), + msg({ id: 'm2', created_at: created, tools: [toolResult('run', 't2')] }) + ] + const steps: PlanStep[] = [ + { id: 's1', seq: 1, title: 'A', detail: '', status: 'done', started_at: created } + ] + const task = { id: 'sess', outcome: 'success', summary: 'done' } as unknown as Session + const frozen = new Map() + const a = computeActivityLog(msgs, steps, task, frozen) + await sleep(50) + const b = computeActivityLog(msgs, steps, task, frozen) + expect(b.map((e) => [e.id, e.timestamp, e.type])).toEqual( + a.map((e) => [e.id, e.timestamp, e.type]) + ) + }) + + it('uses a step started_at instead of falling back to now', () => { + const started = '2026-07-29T20:07:40Z' + const steps: PlanStep[] = [ + { id: 's1', seq: 1, title: 'A', detail: '', status: 'done', started_at: started } + ] + const entries = computeActivityLog([], steps, null, new Map()) + expect(entries.find((e) => e.id === 's1')!.timestamp).toBe(new Date(started).getTime()) + }) +}) + +// F4 (plan 2026-08-03): the timeline must be generation-aware. A re-proposed +// task persists every generation's propose_plan / update_plan_step calls; before +// the fix that produced N "Proposed plan" entries and tagged current-gen tools +// with seqs inferred from superseded generations. Only the LAST propose_plan is +// the live plan; earlier ones collapse to one "Earlier plan revised" marker, and +// step-attribution only follows the current generation. +describe('computeActivityLog generation awareness (F4)', () => { + it('renders one Proposed plan + a revised marker, and attributes tools to the current gen only', () => { + const created = '2026-07-29T20:08:10Z' + // Generation 1: propose → step 1 running → run. Then generation 2 (re-plan). + const gen1 = msg({ + id: 'm1', + created_at: created, + tools: [ + toolUse('propose_plan', 'p1'), + toolUse('update_plan_step', 'u1', { seq: 1, status: 'running' }), + toolResult('run', 'r1') + ] + }) + const gen2 = msg({ + id: 'm2', + created_at: created, + tools: [ + toolUse('propose_plan', 'p2'), + toolUse('update_plan_step', 'u2', { seq: 1, status: 'running' }), + toolResult('run', 'r2') + ] + }) + // Current-generation plan step (gen 2), as fetchPlan (MAX generation) returns. + const steps: PlanStep[] = [ + { id: 's-gen2', seq: 1, title: 'Gen2 step', detail: '', status: 'done', started_at: created } + ] + + const entries = computeActivityLog([gen1, gen2], steps, null, new Map()) + + // Exactly one "Proposed plan" (the current generation's). + const proposals = entries.filter((e) => e.description === 'Proposed plan') + expect(proposals.length).toBe(1) + + // One collapsed marker for the superseded generation(s). + expect(entries.filter((e) => e.description === 'Earlier plan revised').length).toBe(1) + + // The current-gen run is tagged with step 1 (from gen2's update_plan_step). + const r2 = entries.find((e) => e.id === 'r2') + expect(r2, 'gen2 run entry should exist').toBeDefined() + expect(r2!.stepSeq).toBe(1) + + // The superseded-gen run is NOT tagged with a current-gen step (its + // update_plan_step belonged to the replaced generation). + const r1 = entries.find((e) => e.id === 'r1') + expect(r1, 'gen1 run entry should exist').toBeDefined() + expect(r1!.stepSeq).toBeUndefined() + }) + + it('plan-less Q&A still attributes nothing to a step (no propose_plan at all)', () => { + const m = msg({ id: 'm1', tools: [toolResult('get_entity', 't1')] }) + const entries = computeActivityLog([m], [], null, new Map()) + expect(entries.filter((e) => e.description === 'Proposed plan')).toHaveLength(0) + expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined() + }) +}) + +// toolResultSummary (chat interaction overhaul): a one-line, humanized outcome +// per tool so each inline tool line reads as a result instead of raw JSON. +describe('toolResultSummary', () => { + type TR = NonNullable[number] + const done = (name: string, result: unknown, args?: Record): TR => ({ + type: 'tool_result', + name, + id: name, + result, + args + }) + + it('is empty for a still-running call and for an errored one', () => { + expect(toolResultSummary({ type: 'tool_use', name: 'run', id: 'r' })).toBe('') + expect( + toolResultSummary({ type: 'tool_result', name: 'run', id: 'r', error: 'boom' }) + ).toBe('') + }) + + it('parses run exit status', () => { + expect( + toolResultSummary(done('run', 'run on lxc:caddy: ERROR exit status 1')) + ).toContain('exit 1') + }) + + it('summarizes a clean run with its first line', () => { + const s = toolResultSummary(done('run', 'Active: active (running)')) + expect(s.startsWith('ok')).toBe(true) + expect(s).toContain('active') + }) + + it('formats get_entity as slug (health)', () => { + expect( + toolResultSummary(done('get_entity', { slug: 'host:hubris', health: 'healthy' })) + ).toBe('host:hubris (healthy)') + }) + + it('counts list results', () => { + expect( + toolResultSummary(done('list_entities', { entities: Array(10).fill({}) })) + ).toBe('10 entities') + expect(toolResultSummary(done('list_lxcs', { containers: [1, 2] }))).toBe('2 containers') + }) + + it('formats fleet health counts', () => { + expect( + toolResultSummary( + done('get_health_summary', { health: { healthy: 5, degraded: 1, down: 0, unknown: 2 } }) + ) + ).toBe('healthy 5 · degraded 1 · down 0 · unknown 2') + }) + + it('extracts the knowledge slug from upsert_knowledge', () => { + expect( + toolResultSummary(done('upsert_knowledge', 'Saved document:nomos/foo-bar to the DB')) + ).toBe('recorded document:nomos/foo-bar') + }) + + it('formats update_plan_step from args', () => { + expect( + toolResultSummary(done('update_plan_step', 'ok', { seq: 2, status: 'done' })) + ).toBe('step 2 → done') + }) + + it('counts proposed plan steps', () => { + expect(toolResultSummary(done('propose_plan', { steps: [{}, {}, {}] }))).toBe('3 steps') + }) + + it('falls back to the first line for unmapped tools', () => { + expect(toolResultSummary(done('some_new_tool', 'first line\nsecond line'))).toBe('first line') + }) +}) diff --git a/web/src/lib/stores/activity.ts b/web/src/lib/stores/activity.ts new file mode 100644 index 0000000..724626e --- /dev/null +++ b/web/src/lib/stores/activity.ts @@ -0,0 +1,580 @@ +import { derived, type Readable } from 'svelte/store' +import { messages, chatFor, currentSession, type ChatMessage, type ToolCallResult } from './chat' +import { planSteps, currentTask, workspaceFor, taskFor } from './workspace' +import { liveExecutionOutputFor, type LiveExecutionOutput } from './execstream' +import type { PlanStep, Session } from '$lib/api' + +export { type ToolCallResult } + +export interface ActivityEntry { + id: string + type: + | 'goal' + | 'plan' + | 'step_running' + | 'step_done' + | 'step_failed' + | 'tool_running' + | 'tool_done' + | 'tool_error' + | 'knowledge' + | 'complete' + | 'question' + | 'error' + description: string + detail?: string + args?: string + timestamp: number + toolName?: string + stepSeq?: number + indent?: boolean + status: 'running' | 'done' | 'failed' + // Command output streaming in while a `run` tool call is still executing. + // Distinct from `detail`, which is only populated once the tool_result + // arrives — for an auto-run that is the moment the command finishes. + liveOutput?: string + // Deep link to an artifact this entry references — a recorded knowledge doc + // or a looked-up entity — so the operator can open it directly instead of + // having to navigate there by hand. Rendered as a clickable chip in the + // timeline (F5). `slug` is an entity slug (e.g. "document:nomos/…"). + link?: { kind: 'knowledge' | 'entity'; slug: string } +} + +// Detail text is kept full-length (not hard-truncated to a preview snippet) +// so the expanded view has something worth pretty-printing — capped only as +// a safety net against pathological payloads (a full fleet dump, etc). +const DETAIL_MAX = 8000 + +function summarizeArgs(args: unknown): string | undefined { + if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined + if (Object.keys(args).length === 0) return undefined + try { + return JSON.stringify(args) + } catch { + return undefined + } +} + +function stringifyResult(result: unknown): string { + const s = typeof result === 'string' ? result : JSON.stringify(result ?? '') + return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s +} + +// A knowledge doc slug as printed in upsert_knowledge's result text — mirrors +// cmd/nomos/store.go's knowledgeSlugRe (e.g. "document:nomos/some-finding"). +const KNOWLEDGE_SLUG_RE = /[a-z]+:nomos\/[a-z0-9-]+/ +// An entity slug looks like "type:name" (host:strong, lxc:caddy); a bare UUID +// or free text doesn't, so we only deep-link when it does. +const ENTITY_SLUG_RE = /^[a-z][a-z0-9_]*:[^\s]+$/ + +// entityLinkFromArgs pulls a navigable slug out of a get_entity-style call's +// args so its activity entry can link straight to that entity's window (F5). +function entityLinkFromArgs(args: unknown): ActivityEntry['link'] | undefined { + if (!args || typeof args !== 'object') return undefined + const slug = (args as Record)?.slug_or_id + if (typeof slug === 'string' && ENTITY_SLUG_RE.test(slug)) { + return { kind: 'entity', slug } + } + return undefined +} + +// knowledgeLinkFromResult extracts the created doc's slug from an +// upsert_knowledge result so the "Recorded: …" entry links to the doc (F5). +function knowledgeLinkFromResult(result: unknown): ActivityEntry['link'] | undefined { + const s = typeof result === 'string' ? result : JSON.stringify(result ?? '') + const m = s.match(KNOWLEDGE_SLUG_RE) + return m ? { kind: 'knowledge', slug: m[0] } : undefined +} + +// Pure derivation, parameterized so it can back both the global "current +// session" activityLog below and a per-session activityLogFor(sessionId) for +// a floating task window. +// +// Timestamps are REAL where the data has them and FROZEN where it doesn't: +// - persisted tool calls use their message's created_at (a true time); +// - steps use their started_at when present; +// - only genuinely-live entries (a tool call on the in-flight message that +// has no created_at yet) fall back to wall-clock, and that value is frozen +// into `frozen` on FIRST sight so a re-derive (the 3s poller re-sets +// `messages` every tick) reads the same value instead of marching every +// entry forward. Before this, every entry's time was +// `now - (len - index) * 1000` — fabricated at render time and churning +// every poll (P0.2). `frozen` is owned by the caller and lives across +// re-derivations; pass a fresh Map for a purity test. +export function computeActivityLog( + $msgs: ChatMessage[], + $steps: PlanStep[], + $task: Session | null, + frozen: Map +): ActivityEntry[] { + const entries: ActivityEntry[] = [] + const now = Date.now() + // freeze: prefer a real persisted time; else reuse a value already pinned + // for this id; else pin wall-clock now and remember it. + const freeze = (id: string, real?: number | null): number => { + if (real && real > 0) return real + const hit = frozen.get(id) + if (hit !== undefined) return hit + frozen.set(id, now) + return now + } + const tsOf = (iso?: string): number | undefined => + iso ? new Date(iso).getTime() || undefined : undefined + + // Goal + if ($task?.goal) { + entries.push({ + id: 'goal', + type: 'goal', + description: $task.goal, + timestamp: 0, + status: 'done' + }) + } + + // Plan steps + for (const s of $steps) { + if (s.status === 'pending') continue + const stepLabel = s.title || `Step ${s.seq}` + entries.push({ + id: s.id, + type: + s.status === 'running' ? 'step_running' : s.status === 'done' ? 'step_done' : 'step_failed', + description: `Step ${s.seq}: ${stepLabel}`, + detail: s.detail || undefined, + timestamp: freeze(s.id, tsOf(s.started_at)), + status: s.status === 'running' ? 'running' : s.status === 'done' ? 'done' : 'failed' + }) + } + + // Tool calls (from messages). Tag each tool with the plan step that's + // currently running when it fires. + // + // Generation awareness (plan 2026-08-03 F4): a re-proposed task persists + // every generation's propose_plan/update_plan_step calls. Without scoping, + // the timeline rendered N "Proposed plan" entries and inferred + // currentStepSeq from superseded generations — tools landed under the wrong + // (current-gen) step and it read as "several plans, some never run." So: + // only the LAST propose_plan is the live plan; earlier ones collapse to a + // single "Earlier plan revised" marker, and update_plan_step step-tracking + // only applies to the current generation. + let lastPlanMi = -1 + let lastPlanTi = -1 + let planCount = 0 + for (let mi = 0; mi < $msgs.length; mi++) { + for (let ti = 0; ti < $msgs[mi].tools.length; ti++) { + if ($msgs[mi].tools[ti].name === 'propose_plan') { + planCount++ + lastPlanMi = mi + lastPlanTi = ti + } + } + } + const revised = planCount > 1 + + let currentStepSeq = 0 + let entryIdx = 0 + // No plan at all (plan-less Q&A) → treat the whole transcript as current. + let sawCurrentPlan = planCount === 0 + let emittedRevised = false + for (let mi = 0; mi < $msgs.length; mi++) { + const msgTs = tsOf($msgs[mi].created_at) + for (let ti = 0; ti < $msgs[mi].tools.length; ti++) { + const t = $msgs[mi].tools[ti] + const isLastPlan = mi === lastPlanMi && ti === lastPlanTi + if (isLastPlan) sawCurrentPlan = true + + // Track current step ONLY from the current generation's + // update_plan_step calls; a superseded generation's seqs would tag + // tools with the wrong (current-gen) step. + if (sawCurrentPlan && t.type === 'tool_use' && t.name === 'update_plan_step') { + const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined + const status = typeof t.args?.status === 'string' ? t.args.status : undefined + if (s && status === 'running') currentStepSeq = s + } else if (t.name === 'set_goal' || t.name === 'propose_plan' || t.name === 'complete_task') { + currentStepSeq = 0 + } + + // Skip superseded-generation propose_plan entries; emit one collapsed + // "revised" marker so a re-proposal stays visible without reading as a + // second active plan. + if (t.name === 'propose_plan' && !isLastPlan) { + if (revised && !emittedRevised) { + emittedRevised = true + entries.push({ + id: `plan_revised_${mi}_${ti}`, + type: 'plan', + description: 'Earlier plan revised', + timestamp: freeze(`plan_revised_${mi}_${ti}`, msgTs), + status: 'done' + }) + } + continue + } + + const label = toolActivityLabel(t) + const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined + const id = t.id ?? `tool_${mi}_${entryIdx++}` + if (t.type === 'tool_use') { + entries.push({ + id, + type: 'tool_running', + description: label, + args: summarizeArgs(t.args), + timestamp: freeze(id, msgTs), + toolName: t.name, + stepSeq: stepTag, + indent: stepTag != null, + status: 'running' + }) + } else if (t.type === 'tool_result') { + const running = entries.find( + (e) => e.type === 'tool_running' && e.id === t.id && e.status === 'running' + ) + if (running && t.error) { + running.type = 'tool_error' + running.status = 'failed' + running.description = `${label}: ${t.error.slice(0, 80)}` + running.detail = t.error + } else if (running) { + running.type = 'tool_done' + running.status = 'done' + running.detail = stringifyResult(t.result) + if (t.name === 'get_entity' || t.name === 'get_entity_knowledge') { + running.link = entityLinkFromArgs(t.args) + } + } else { + // Historical/persisted tool calls arrive as one merged record (args + // + result on the same object, see mergeToolCalls in chat.ts) rather + // than a separate tool_use/tool_result pair — there's never a + // "running" entry to attach to, so this branch has to build the + // full entry itself. It used to fall back to the raw tool name + // (e.g. "get_entity") instead of the humanized label here. + entries.push({ + id, + type: t.error ? 'tool_error' : 'tool_done', + description: t.error ? `${label}: ${t.error.slice(0, 80)}` : label, + detail: t.error ? t.error : stringifyResult(t.result), + args: summarizeArgs(t.args), + timestamp: freeze(id, msgTs), + toolName: t.name, + stepSeq: stepTag, + indent: stepTag != null, + status: t.error ? 'failed' : 'done', + link: + t.name === 'get_entity' || t.name === 'get_entity_knowledge' + ? entityLinkFromArgs(t.args) + : undefined + }) + } + } + } + } + + // Knowledge recorded — detect from upsert_knowledge tool results + for (let mi = 0; mi < $msgs.length; mi++) { + const msgTs = tsOf($msgs[mi].created_at) + for (const t of $msgs[mi].tools) { + if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) { + const title = typeof t.args?.title === 'string' ? t.args.title : '' + const kid = `knowledge_${mi}_${t.id ?? ''}` + entries.push({ + id: kid, + type: 'knowledge', + description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge', + timestamp: freeze(kid, msgTs), + status: 'done', + link: knowledgeLinkFromResult(t.result) + }) + } + } + } + + // Task completion + if ($task?.outcome) { + entries.push({ + id: 'complete', + type: 'complete', + description: $task.summary || `Task ${$task.outcome}`, + timestamp: freeze('complete'), + status: $task.outcome === 'failure' ? 'failed' : 'done' + }) + } + + // Note: approval entries were removed from activityLog (2026-07-15). + // They were always `status: 'running'` and never transitioned to 'done' + // (the derived store builds from tool-call text, not execution status), + // which caused the AgentIndicator to latch onto a stale "Approval: ..." + // entry and never clear — even after the session completed. Approvals + // are tracked via the REST /approvals endpoint (context.ts, Ops.svelte) + // and rendered as inline approval cards in the chat (or Ops page), not + // in the activity log. + + // Sort oldest first + entries.sort((a, b) => a.timestamp - b.timestamp) + + return entries +} + +// A per-store freeze map: the first time a live entry (no real timestamp +// yet) is seen, its wall-clock time is pinned here so the 3s poller's +// re-derivation can't march it forward. Owned here, outside the derivation, +// so it survives re-runs. The per-session path has its own Map keyed by id. +const frozenTimestamps = new Map() + +// Live execution output for whichever session the global "current session" +// view is on — used to attach streaming `run` output to the global activityLog +// (the per-window activityLogFor has its own). Follows currentSession via a +// derived setup function so the subscription moves to the right session's +// store when the operator switches tasks. +const currentLiveOutput = derived( + currentSession, + ($sid, set) => { + if (!$sid) { + set(null) + return + } + return liveExecutionOutputFor($sid).subscribe(set) + }, + null as LiveExecutionOutput | null +) + +export const activityLog = derived( + [messages, planSteps, currentTask, currentLiveOutput], + ([$msgs, $steps, $task, $live]) => + withLiveOutput(computeActivityLog($msgs, $steps, $task, frozenTimestamps), $live) +) + +// Attach streaming output to the `run` entry that is currently executing. +// Nomos runs tools sequentially, so the last still-running run entry is the +// one the output belongs to. +function withLiveOutput( + entries: ActivityEntry[], + live: LiveExecutionOutput | null +): ActivityEntry[] { + if (!live?.output) return entries + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i] + if (e.type === 'tool_running' && e.status === 'running' && e.toolName === 'run') { + entries[i] = { ...e, liveOutput: live.output } + break + } + } + return entries +} + +// One freeze map per session window (entry ids are UUIDs, but the synthetic +// 'goal'/'complete' ids collide across sessions, so each window keeps its own). +const sessionFrozenTimestamps = new Map>() +export function activityLogFor(sessionId: string): Readable { + const chat = chatFor(sessionId) + const ws = workspaceFor(sessionId) + const task = taskFor(sessionId) + const live = liveExecutionOutputFor(sessionId) + let frozen = sessionFrozenTimestamps.get(sessionId) + if (!frozen) { + frozen = new Map() + sessionFrozenTimestamps.set(sessionId, frozen) + } + return derived([chat.messages, ws.planSteps, task, live], ([$msgs, $steps, $task, $live]) => + withLiveOutput(computeActivityLog($msgs, $steps, $task, frozen), $live) + ) +} + +// Humanized, past/present-tense description of what a tool call is doing +// ("Check execution", "Research: …") rather than its raw wire name. Exported +// so the chat's agent trace can read as a thinking log instead of an API log. +export function toolActivityLabel(t: ToolCallResult): string { + const args = t.args ?? {} + const str = (v: unknown): string => (typeof v === 'string' ? v : '') + switch (t.name) { + case 'set_goal': + return 'Set goal' + case 'propose_plan': + return 'Proposed plan' + case 'search_knowledge': + return `Research: ${str(args.query)}` + case 'get_entity': + return `Lookup: ${str(args.slug_or_id)}` + case 'get_entity_knowledge': + return 'Check prior knowledge' + case 'get_relations': + return 'Check relationships' + case 'list_lxcs': + return 'List containers' + case 'list_entities': + return 'List entities' + case 'get_health_summary': + return 'Fleet health' + case 'get_state_snapshot': + return 'State snapshot' + case 'run': { + const purpose = str(args.purpose) + const target = str(args.target) + if (purpose) return purpose + if (target) return `Run on ${target}` + return 'Run command' + } + case 'get_execution_status': + return 'Check execution' + case 'update_plan_step': + return 'Update plan' + case 'upsert_knowledge': + return 'Record knowledge' + case 'complete_task': + return 'Complete task' + case 'ping_service': + return 'Check service' + case 'ask_operator': + return 'Ask operator' + // Unmapped tool (new/uncommon) — humanize the raw name rather than + // showing it verbatim, e.g. "revoke_execution" -> "Revoke execution". + default: + return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()) + } +} + +// ── toolResultSummary ──────────────────────────────────────────────────── +// A one-line, humanized summary of a tool's RESULT (the Claude-Code-style +// "exit 0 · " / "host:hubris (healthy)" affordance) so each tool line +// in the inline trace reads as an outcome instead of a raw JSON blob. Empty +// for a still-running call (no result yet) or an errored one (the error is +// surfaced separately). Best-effort by tool name; the fallback is the first +// non-empty line of the stringified result, truncated — never blank (the +// expandable raw detail is always one click away). +function resultAsString(r: unknown): string { + if (r == null) return '' + if (typeof r === 'string') return r + try { + return JSON.stringify(r) + } catch { + return String(r) + } +} +function firstLine(s: string, max = 80): string { + const line = s + .split(/\r?\n/) + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? '' + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} +function resultArray(r: unknown): unknown[] | null { + if (Array.isArray(r)) return r + if (r && typeof r === 'object') { + const o = r as Record + for (const k of [ + 'entities', + 'results', + 'relations', + 'steps', + 'items', + 'containers', + 'docs', + 'questions', + 'signals', + 'events', + 'patterns', + 'skills' + ]) { + if (Array.isArray(o[k])) return o[k] as unknown[] + } + } + return null +} +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null +} +function plural(n: number, singular: string, pluralForm = `${singular}s`): string { + return `${n} ${n === 1 ? singular : pluralForm}` +} +export function toolResultSummary(t: ToolCallResult): string { + if (t.type === 'tool_use') return '' // still running + if (t.error) return '' // error surfaced separately + const args = t.args ?? {} + const str = (v: unknown): string => (typeof v === 'string' ? v : '') + const obj = (r: unknown): Record | null => + r && typeof r === 'object' && !Array.isArray(r) ? (r as Record) : null + switch (t.name) { + case 'run': { + const s = resultAsString(t.result) + const m = s.match(/exit (?:status )?(\d+)/i) + const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok' + const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60) + return rest ? `${tag} · ${rest}` : tag + } + case 'get_entity': { + const o = obj(t.result) + const slug = str(o?.slug) || str(args.slug_or_id) + const health = str(o?.health) || str(o?.state) + return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found' + } + case 'get_relations': { + const a = resultArray(t.result) + return a ? plural(a.length, 'relation') : 'done' + } + case 'list_entities': + case 'list_lxcs': { + const a = resultArray(t.result) + if (!a) return 'done' + return t.name === 'list_lxcs' + ? plural(a.length, 'container') + : plural(a.length, 'entity', 'entities') + } + case 'get_health_summary': { + const o = obj(t.result) + const h = (o?.health && obj(o.health)) || o + if (h) { + const parts = ['healthy', 'degraded', 'down', 'unknown'] + .map((k) => { + const n = num((h as Record)[k]) + return n != null ? `${k} ${n}` : null + }) + .filter((p): p is string => p != null) + if (parts.length) return parts.join(' · ') + } + return 'done' + } + case 'get_state_snapshot': { + const o = obj(t.result) + const drift = num(o?.drift ?? o?.drift_count) + return drift != null ? plural(drift, 'drift') : 'done' + } + case 'search_knowledge': + case 'get_entity_knowledge': + case 'get_patterns': + case 'get_skills': { + const a = resultArray(t.result) + return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done' + } + case 'upsert_knowledge': { + const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/) + return m ? `recorded ${m[0]}` : 'recorded' + } + case 'update_plan_step': { + const seq = num(args.seq) + const status = str(args.status) + if (seq != null && status) return `step ${seq} → ${status}` + return status || 'updated' + } + case 'propose_plan': { + const a = resultArray(t.result) ?? resultArray(args.steps) + return a ? plural(a.length, 'step') : 'planned' + } + case 'set_goal': + return 'goal set' + case 'complete_task': + return str(args.outcome) || 'complete' + case 'ask_operator': + return 'asked' + case 'ping_service': { + const s = resultAsString(t.result).toLowerCase() + return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done' + } + case 'get_execution_status': { + const o = obj(t.result) + return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done' + } + default: + return firstLine(resultAsString(t.result)) || 'done' + } +} diff --git a/web/src/lib/stores/background.svelte.ts b/web/src/lib/stores/background.svelte.ts new file mode 100644 index 0000000..b925804 --- /dev/null +++ b/web/src/lib/stores/background.svelte.ts @@ -0,0 +1,123 @@ +import { PATTERNS, type BackgroundPatternId } from '$lib/desktop-patterns' + +export interface BackgroundConfig { + pattern: BackgroundPatternId + color: string // pattern foreground color, hex + fillColor: string | null // fill behind the pattern (its "gaps"); null = transparent, i.e. the app's own theme background shows through + opacity: number // 0..1 + fade: number // 0..1 — 0 disables the vignette mask entirely + scale: number // pattern tile-size multiplier; 1 = natural size + rotation: number // degrees, 0..359 +} + +const STORAGE_KEY = 'oikos-desktop-bg' +const VALID_IDS = new Set(PATTERNS.map((p) => p.id)) + +// Matches the previous hardcoded desktop background (bubbles, brand blue, +// tuned low so it reads as texture rather than noise) so this feature ships +// as "now configurable" rather than a visual regression on first load. +const DEFAULTS: BackgroundConfig = { + pattern: 'bubbles', + color: '#444cf7', + fillColor: null, + opacity: 0.08, + fade: 0, + scale: 1, + rotation: 0 +} + +function clamp01(v: unknown, fallback: number): number { + const n = typeof v === 'number' ? v : Number(v) + if (!Number.isFinite(n)) return fallback + return Math.min(1, Math.max(0, n)) +} + +function clampScale(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v) + if (!Number.isFinite(n)) return DEFAULTS.scale + return Math.min(3, Math.max(0.4, n)) +} + +function clampRotation(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v) + if (!Number.isFinite(n)) return DEFAULTS.rotation + return ((n % 360) + 360) % 360 +} + +function isHexColor(c: unknown): c is string { + return typeof c === 'string' && /^#[0-9a-fA-F]{6}$/.test(c) +} + +function storedConfig(): BackgroundConfig { + if (typeof localStorage === 'undefined') return DEFAULTS + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULTS + try { + const parsed = JSON.parse(raw) + return { + pattern: VALID_IDS.has(parsed.pattern) ? parsed.pattern : DEFAULTS.pattern, + color: isHexColor(parsed.color) ? parsed.color : DEFAULTS.color, + fillColor: + parsed.fillColor === null + ? null + : isHexColor(parsed.fillColor) + ? parsed.fillColor + : DEFAULTS.fillColor, + opacity: clamp01(parsed.opacity, DEFAULTS.opacity), + fade: clamp01(parsed.fade, DEFAULTS.fade), + scale: clampScale(parsed.scale), + rotation: clampRotation(parsed.rotation) + } + } catch { + return DEFAULTS + } +} + +let config: BackgroundConfig = $state(storedConfig()) + +function persist(): void { + if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) +} + +export function getBackground(): BackgroundConfig { + return config +} + +export function setBackgroundPattern(pattern: BackgroundPatternId): void { + config = { ...config, pattern } + persist() +} + +export function setPatternColor(color: string): void { + if (!isHexColor(color)) return + config = { ...config, color } + persist() +} + +// null clears back to "auto" (transparent — the app's theme background +// shows through the pattern's gaps). +export function setFillColor(color: string | null): void { + if (color !== null && !isHexColor(color)) return + config = { ...config, fillColor: color } + persist() +} + +export function setBackgroundOpacity(opacity: number): void { + config = { ...config, opacity: clamp01(opacity, DEFAULTS.opacity) } + persist() +} + +export function setBackgroundFade(fade: number): void { + config = { ...config, fade: clamp01(fade, DEFAULTS.fade) } + persist() +} + +export function setBackgroundScale(scale: number): void { + config = { ...config, scale: clampScale(scale) } + persist() +} + +export function setBackgroundRotation(rotation: number): void { + config = { ...config, rotation: clampRotation(rotation) } + persist() +} diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts new file mode 100644 index 0000000..0430990 --- /dev/null +++ b/web/src/lib/stores/chat.ts @@ -0,0 +1,973 @@ +import { writable, get, type Writable } from 'svelte/store' +import { + streamChat, + fetchSessions, + fetchMessages, + fetchMessagesOrNotFound, + deleteSession as apiDeleteSession +} from '$lib/api' +import type { ChatEvent, Session, Message } from '$lib/api' +import type { ToolCallResult } from '$lib/types' +import { liveEvents, subscribeEvents } from './events' + +export type { ToolCallResult } + +export interface PendingApproval { + executionId: string + action: string + target: string + destructive: boolean + command?: string + purpose?: string +} + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' + text: string + thinking?: string + tools: ToolCallResult[] + pendingApprovals: PendingApproval[] + created_at?: string +} + +const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i + +// Deliberately NOT filtered by tool name. There is no fixed set of gated +// tools — `run` can execute anything, and any future tool that queues an +// approval should surface a card the same way. A prior version hardcoded +// `t.name === 'request_execution'`, so approvals raised by the newer `run` +// tool were silently invisible in chat: no card, no feedback, nothing to +// self-heal, forcing the operator to the Ops page with zero acknowledgement +// back in the conversation. Matching on the response shape (not the tool +// name) is what makes this robust to new gated tools without another +// silent breakage. +function extractApprovals(tools: ToolCallResult[]): PendingApproval[] { + const out: PendingApproval[] = [] + for (const t of tools) { + if (t.type !== 'tool_result') continue + const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? '') + if (!text.includes('requires approval')) continue + const m = text.match(APPROVAL_RE) + if (m) { + const args = t.args ?? {} + const purpose = typeof args.purpose === 'string' ? args.purpose : undefined + out.push({ + executionId: m[1], + action: purpose + ? purpose.slice(0, 60) + : typeof args.action === 'string' + ? args.action + : t.name, + target: typeof args.target === 'string' ? args.target : 'unknown', + destructive: /\bDESTRUCTIVE\b/.test(text), + command: typeof args.command === 'string' ? args.command : undefined, + purpose + }) + } + } + return out +} + +function mid(): string { + return crypto.randomUUID() +} + +// dropOptimisticAssistantBubble removes the trailing empty assistant message +// that sendSessionMessage/startTask optimistically append — used when a turn is +// QUEUED behind an in-flight one (plan 2026-08-03 F2): no live assistant stream +// is attached, so the empty placeholder must go (otherwise it lingers as a +// blank bubble). Shared so the guard can't drift between the two call sites. +function dropOptimisticAssistantBubble(messages: Writable): void { + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant' && last.text === '' && last.tools.length === 0) { + return ms.slice(0, -1) + } + return ms + }) +} + +export const messages = writable([]) +export const streaming = writable(false) +export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected') +export const currentSession = writable(null) +export const sessions = writable([]) +export const sessionMessages = writable([]) +export const error = writable(null) +export const chatErrors = writable<{ id: string; message: string; action?: string; tag?: string }[]>([]) + +export function dismissError(id: string) { + chatErrors.update((e) => e.filter((x) => x.id !== id)) +} + +export function addChatError(message: string, action?: string, tag?: string) { + chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action, tag }]) +} + +// Session statuses where no turn is running — the agent reached a terminal +// state (done/failed/abandoned) or paused for operator input (awaiting_input). +// A task.status event landing in one of these is an authoritative "the turn +// ended" signal, used by clearTurnState (F3) to unstick a chat view that lost +// its SSE stream mid-turn. +const TURN_ENDED_STATUS = new Set(['done', 'failed', 'abandoned', 'awaiting_input']) + +// humanizeChatError turns raw transport/SDK error strings into operator- +// readable, non-alarming copy. The raw forms ("llm: error in input stream: +// …", "Failed to fetch", "HTTP 502") read as catastrophic and unactionable; +// most are transient model-connection drops where the task itself is fine. +// Used for both the inline error box (LLM error events) and the connection +// toast (F2). +function humanizeChatError(raw: string): string { + const s = raw.toLowerCase() + if ( + s.includes('input stream') || + s.includes('llm:') || + s.includes('failed to fetch') || + s.includes('network') || + s.includes('econnreset') || + s.includes('timeout') || + /http 5\d\d/.test(s) + ) { + return 'The model connection dropped. The task keeps running in the background — it will catch up here automatically.' + } + if (/http 401|http 403|unauthor|forbidden/.test(s)) { + return 'Your session expired. Reconnect to continue.' + } + return raw +} + +// clearTurnState resets a session's chat view to a clean "connected, idle" +// state — the recovery action when a dropped SSE left it stuck showing +// streaming/disconnected after the turn had already ended. Clears the window +// bundle, the global bundle (if that's the viewed session), and any +// connection-lost toasts tagged 'connection' (F2/F3). +function clearTurnState(sessionId: string) { + const win = sessionChats.get(sessionId) + if (win) { + win.streaming.set(false) + win.connectionState.set('connected') + } + if (get(currentSession) === sessionId) { + streaming.set(false) + connectionState.set('connected') + } + chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection')) +} + +// One app-lifetime subscription to the always-on event stream: a terminal +// task.status for a session we have open is the authoritative end-of-turn +// signal, and recovers a chat view whose SSE dropped without a 'done' event +// (the "task never ended" symptom). Ref-counted by subscribeEvents, so this +// shares the single connection the rest of the app already keeps open. +// +// Lazily armed from chatFor() (P2.2) rather than at module import, so +// importing this module — e.g. in a test — doesn't open an SSE connection as +// an import side-effect. +let chatEventSyncArmed = false +function ensureChatEventSync() { + if (chatEventSyncArmed) return + chatEventSyncArmed = true + subscribeEvents() + liveEvents.subscribe((events) => { + const ev = events[0] + if (!ev || ev.type !== 'task.status') return + const sid = ev.correlation_id + if (!sid) return + const status = (ev.data as { status?: string } | null)?.status + if (typeof status === 'string' && TURN_ENDED_STATUS.has(status)) { + clearTurnState(sid) + } + }) +} + +// Per-session controller tracking. Multiple tasks can stream concurrently +// (see sendMessage's session guard above this used to be a single global +// `activeController`, which meant cancelStream()/newChat() always aborted +// whichever stream happened to be the MOST RECENTLY started one, regardless +// of what the operator was currently viewing — starting Task A, switching to +// (already-loaded) Task B, then clicking "New task" would silently abort +// Task A's still-running turn even though the operator was never looking at +// it and never asked to cancel it. Keyed by session id once known; +// pendingController covers the brief window for a brand-new task between +// streamChat() starting and its 'session' event assigning a real id. +const activeControllers = new Map() +let pendingController: AbortController | null = null + +export async function loadSessions() { + const list = await fetchSessions() + sessions.set(list) +} + +// mergeToolCalls collapses a persisted tool_calls array into one entry per +// call id. Nomos persists the tool_use and tool_result as two separate +// entries sharing the same id (matching the SSE event pair); Chat.svelte +// renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate +// keys and silently aborts the whole message list. Live-streamed messages +// never hit this because sendMessage() merges tool_result into the existing +// tool_use entry in place rather than appending a second one. +function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] { + const byId = new Map() + for (const tc of raw ?? []) { + const key = tc.id ?? crypto.randomUUID() + const existing = byId.get(key) + byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key }) + } + return Array.from(byId.values()) +} + +function toChatMessages(msgs: Message[]): ChatMessage[] { + return msgs.map((m) => { + const content = typeof m.content === 'string' ? { text: m.content } : m.content + const tools = mergeToolCalls(content?.tool_calls) + return { + id: m.id, + role: m.role as 'user' | 'assistant', + text: content?.text ?? '', + thinking: content?.thinking ?? undefined, + tools, + pendingApprovals: extractApprovals(tools), + created_at: m.created_at + } + }) +} + +function chatMessagesChanged(a: ChatMessage[], b: ChatMessage[]): boolean { + if (a.length !== b.length) return true + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id || a[i].role !== b[i].role || a[i].text !== b[i].text || a[i].tools.length !== b[i].tools.length) { + return true + } + } + return false +} + +export async function loadSessionMessages(sessionId: string) { + currentSession.set(sessionId) + // This is a fresh view of sessionId's current (REST-loaded) state — reset + // streaming regardless of whether some OTHER task's stream happens to still + // be in flight in the background. Without this, switching to a task while + // a different one is mid-turn could leave `streaming` stuck true here (that + // other stream's completion callback now correctly skips touching it, per + // sendMessage's session guard) — which would disable the input AND silently + // stop startPolling's loop from ever applying updates (it bails while + // $streaming is true), making the newly-opened task look frozen. + streaming.set(false) + const msgs = await fetchMessages(sessionId) + sessionMessages.set(msgs) + messages.set(toChatMessages(msgs)) + startPolling(sessionId) +} + +// Live visibility for autonomous work: the auto-continuation worker (see +// cmd/nomos/continue.go) runs entirely server-side and has no live push — +// previously the only way to see its result was to manually reload the +// session, so approving a plan and then waiting felt like nothing was +// happening even while the agent was actively working. This polls the +// session's persisted messages every few seconds and merges in anything new +// (an auto-continuation's result, a fresh pending approval it queued, etc.) +// so the transcript updates on its own. Only runs between turns — never +// while a live streaming turn owns the message list, to avoid clobbering the +// in-progress optimistic UI. +let pollTimer: ReturnType | null = null +let pollingSessionId: string | null = null + +function startPolling(sessionId: string) { + stopPolling() + pollingSessionId = sessionId + pollTimer = setInterval(async () => { + // Allow polling while disconnected — the agent is still working + // server-side and the poller is the only way to see it. + if (get(streaming) && get(connectionState) === 'connected') return + if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return + const msgs = await fetchMessages(sessionId) + if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time + const incoming = toChatMessages(msgs) + if (chatMessagesChanged(get(messages), incoming)) { + sessionMessages.set(msgs) + messages.set(incoming) + } + }, 3000) +} + +export function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + pollingSessionId = null +} + +export function sendMessage(text: string) { + error.set(null) + streaming.set(true) + + const userMsg: ChatMessage = { + id: mid(), + role: 'user', + text, + tools: [], + pendingApprovals: [] + } + messages.update((ms) => [...ms, userMsg]) + + const assistantMsg: ChatMessage = { + id: mid(), + role: 'assistant', + text: '', + tools: [], + pendingApprovals: [] + } + messages.update((ms) => [...ms, assistantMsg]) + + const activeTools: Map = new Map() + + // Multiple tasks can stream concurrently (the backend runs each turn as its + // own goroutine — nothing serializes them), but `messages`/`currentSession` + // are a single global view. Without this guard, switching to a different + // task while this stream is still open lets its later events (tool_use, + // text_delta, ..., and worst of all the 'done' handler's + // currentSession.set) get applied to whatever the operator is NOW looking + // at — silently corrupting another task's transcript, or yanking the view + // back to this one. openedFor is the session this call started for (null + // for a brand-new task, until the 'session' event assigns the real id); + // every branch below checks the CURRENT $currentSession still matches + // before touching `messages`. The task itself keeps running server-side + // regardless — dropped events just mean the live view isn't watching it; + // navigating back re-hydrates via REST/poll same as it already does for + // auto-continuation. + const openedFor = get(currentSession) + let streamSessionID = openedFor + let receivedDone = false + + const controller = streamChat( + text, + get(currentSession), // continue the active session so the agent keeps context + (ev: ChatEvent) => { + if (ev.type === 'session') { + streamSessionID = ev.data + // Move this stream's controller into the per-session map now that its + // real id is known, so a later cancelStream()/newChat() from THIS + // session's view can find and abort it — and, just as importantly, + // so cancelling/leaving a DIFFERENT session never reaches this one. + // For a continued (non-new) session, openedFor already equals ev.data + // and the controller was stored under that key at creation below; + // this only does real work for a brand-new task's first assignment. + if (pendingController === controller) pendingController = null + activeControllers.set(ev.data, controller) + // Only claim currentSession if the operator hasn't already navigated + // to something else since this call started (openedFor covers both + // "still on the task I was on" and "still hadn't opened one yet"). + if (get(currentSession) === openedFor) currentSession.set(ev.data) + return + } + if (get(currentSession) !== streamSessionID) return // stream's task isn't the one on screen — drop + if (ev.type === 'tool_use') { + const tr: ToolCallResult = { + type: 'tool_use', + name: ev.data.name, + id: ev.data.id, + args: ev.data.args + } + activeTools.set(ev.data.id, tr) + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] } + } + return [...ms] + }) + } else if (ev.type === 'tool_result') { + const existing = activeTools.get(ev.data.id) + if (existing) { + const updated: ToolCallResult = { + ...existing, + type: 'tool_result', + result: ev.data.result, + error: ev.data.error + } + activeTools.set(ev.data.id, updated) + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) + ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) } + } + return [...ms] + }) + } + } else if (ev.type === 'text_delta') { + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, text: last.text + ev.data } + } + return [...ms] + }) + } else if (ev.type === 'text') { + // Final authoritative content for the turn; replaces accumulated deltas. + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + if (ev.is_thinking) { + ms[ms.length - 1] = { + ...last, + thinking: (last.thinking || '') + ev.data, + text: '' + } + } else { + ms[ms.length - 1] = { ...last, text: ev.data } + } + } + return [...ms] + }) + } else if (ev.type === 'done') { + receivedDone = true + connectionState.set('connected') + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) } + } + return [...ms] + }) + const sid = ev.data?.session_id ?? ev.session_id + // Start polling for auto-continuation results now that the live turn + // is over — this is what makes an approved plan's later steps show up + // on their own instead of requiring a manual reload. (startPolling's + // own loop already re-checks $currentSession before applying results, + // so this is safe to call even if the operator has since navigated + // elsewhere — it just won't visibly do anything until/unless they + // come back.) + if (sid) startPolling(sid) + } else if (ev.type === 'error') { + error.set(ev.data) + } + }, + (err: string) => { + // Distinguish user abort from network drop. + if (err === 'AbortError' || err.includes('aborted')) { + if (get(currentSession) === streamSessionID) streaming.set(false) + return + } + // Network blip / server restart — initiate reconnect. + if (get(currentSession) === streamSessionID) { + error.set(err) + if (!receivedDone && streamSessionID) { + handleDisconnect(streamSessionID) + } else { + streaming.set(false) + } + } + }, + () => { + // SSE stream completed without error. If we never received 'done', + // the connection was severed mid-turn — treat as disconnect. + if (!receivedDone && streamSessionID && get(currentSession) === streamSessionID) { + handleDisconnect(streamSessionID) + } else if (get(currentSession) === streamSessionID) { + streaming.set(false) + } + if (streamSessionID && activeControllers.get(streamSessionID) === controller) { + activeControllers.delete(streamSessionID) + } + if (pendingController === controller) pendingController = null + loadSessions() + } + ) + + // Register immediately (not just inside the 'session' handler above) so a + // cancelStream() during the brief pre-'session' window for a CONTINUED + // session (openedFor already known) can find it right away. + if (openedFor) { + activeControllers.set(openedFor, controller) + } else { + pendingController = controller + } +} + +// handleDisconnect is called when the global SSE stream drops mid-turn +// without a 'done' event. NOTE: the global single-session chat action path +// (sendMessage → this) is not currently wired to any UI — only the +// per-session window path (sendSessionMessage/startTask) is live, which has +// its own inline equivalent. This is kept safe and turn-free in case the +// global path is re-wired: it falls back to polling and surfaces one +// connection toast; recovery is driven by the poller + the terminal +// task.status subscription (clearTurnState), NEVER by POSTing an empty +// message that would spawn a duplicate background turn (F1/F2). +function handleDisconnect(sessionId: string) { + connectionState.set('disconnected') + startPolling(sessionId) + addChatError( + 'Connection to the agent dropped. The task keeps running — it will catch up here automatically.', + 'Dismiss', + 'connection' + ) +} + +// Manual reconnect for the global view (currently unused — windows use +// loadSessionChat via their onReconnect). Re-fetches the transcript and +// resets state; does NOT start a new turn. +export function reconnect() { + const sid = get(currentSession) + if (!sid) return + streaming.set(false) + connectionState.set('connected') + chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection')) + loadSessionMessages(sid) +} + +export function newChat() { + cancelStream() + stopPolling() + connectionState.set('connected') + currentSession.set(null) + messages.set([]) + error.set(null) + chatErrors.set([]) + streaming.set(false) // fresh view — see loadSessionMessages for why this must not depend on cancelStream's own reset +} + +// Cancels the stream for whatever the operator is CURRENTLY VIEWING — never +// some other, unrelated task's background stream. Before per-session +// tracking, this aborted a single global `activeController`, which meant it +// always targeted the MOST RECENTLY STARTED stream regardless of what was on +// screen: start Task A, switch to already-loaded Task B, click "New task" — +// newChat()'s cancelStream() would silently abort Task A's still-running +// turn, even though the operator was never looking at it and never asked to +// cancel it. Now it looks up by $currentSession (or pendingController for +// the brief pre-'session'-event window of a just-started new task) so it can +// only ever touch the stream that belongs to the view being left. +export function cancelStream() { + const sid = get(currentSession) + const controller = sid ? activeControllers.get(sid) : pendingController + if (!controller) return + controller.abort() + if (sid) activeControllers.delete(sid) + if (pendingController === controller) pendingController = null + streaming.set(false) +} + +export async function deleteSession(sessionId: string) { + const ok = await apiDeleteSession(sessionId) + if (!ok) return + if (get(currentSession) === sessionId) { + newChat() + } + loadSessions() +} + +// ─── per-session chat state, for floating task windows ───────────────────── +// +// Everything above this point is the single "whatever's on screen" view used +// by the main Chat page and the chat drawer — one global `currentSession`, +// one `messages` array, guarded so a background stream never clobbers the +// view. Floating task windows break that assumption: several sessions can be +// open and legitimately streaming at once, each wanting its own live +// transcript. Rather than retrofit the guard-heavy logic above (streamed +// events checking `get(currentSession) === streamSessionID` before applying), +// each window gets its own isolated store bundle keyed by session id, so +// there's nothing to guard — events for session X always land in X's own +// bundle regardless of what else is open or on screen. +export interface SessionChatState { + messages: Writable + streaming: Writable + connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'> + error: Writable + // Set by loadSessionChat when the backend 404s the session outright + // (deleted, or an id that was never valid — a stale persisted window, a + // bad deep link). Distinct from a merely-empty transcript, which is the + // normal state for a session that exists but hasn't sent a message yet. + notFound: Writable +} + +const sessionChats = new Map() +const sessionPollers = new Map>() + +// Lazily creates (and memoizes) the store bundle for a session — call this to +// get the stores to subscribe to; it does not fetch anything. +export function chatFor(sessionId: string): SessionChatState { + ensureChatEventSync() // arm the terminal task.status → clearTurnState recovery (P2.2) + let c = sessionChats.get(sessionId) + if (!c) { + c = { + messages: writable([]), + streaming: writable(false), + connectionState: writable('connected'), + error: writable(null), + notFound: writable(false) + } + sessionChats.set(sessionId, c) + } + return c +} + +function startSessionPolling(sessionId: string) { + const existing = sessionPollers.get(sessionId) + if (existing) clearInterval(existing) + const chat = chatFor(sessionId) + sessionPollers.set( + sessionId, + setInterval(async () => { + if (get(chat.streaming) && get(chat.connectionState) === 'connected') return + const msgs = await fetchMessages(sessionId) + if (get(chat.streaming)) return // re-check: the fetch itself takes time + const incoming = toChatMessages(msgs) + if (chatMessagesChanged(get(chat.messages), incoming)) { + chat.messages.set(incoming) + } + // F3 safety net: if we're recovering from a dropped SSE but the + // session's task has already reached a turn-ended status, clear the + // stuck disconnected/streaming flags. Catches the edge where the + // terminal task.status event fired during the brief disconnect window. + if (get(chat.connectionState) !== 'connected') { + const s = get(sessions).find((x) => x.id === sessionId) + if (s?.status && TURN_ENDED_STATUS.has(s.status)) { + clearTurnState(sessionId) + } + } + }, 3000) + ) +} + +export function stopSessionPolling(sessionId: string) { + const t = sessionPollers.get(sessionId) + if (t) { + clearInterval(t) + sessionPollers.delete(sessionId) + } +} + +// Fetches sessionId's current transcript into its own store bundle and +// starts polling it for auto-continuation updates — the per-session +// equivalent of loadSessionMessages, for a window rather than the main view. +export async function loadSessionChat(sessionId: string): Promise { + const chat = chatFor(sessionId) + // A fresh (re)load is a clean view: not streaming, connected, no stale + // error. This also serves the window's manual "Reconnect" button — + // re-fetching the transcript and resetting state, never spawning a new + // turn (the old reconnect path POSTed an empty message that started a + // duplicate background turn; F1/F2 removed that). + chat.streaming.set(false) + chat.connectionState.set('connected') + chat.error.set(null) + chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection')) + const msgs = await fetchMessagesOrNotFound(sessionId) + if (msgs === null) { + chat.notFound.set(true) + return // nothing to poll — the session doesn't exist + } + chat.messages.set(toChatMessages(msgs)) + startSessionPolling(sessionId) +} + +// Per-session equivalent of sendMessage — writes into sessionId's own store +// bundle unconditionally (no "is this still on screen" guard needed, since +// the bundle IS the screen for this session's window) and shares +// `activeControllers` with the singleton path above so cancelStream() from +// either a window or the main view (if the same session happens to be open +// in both) finds the same in-flight call. +export function sendSessionMessage(sessionId: string, text: string) { + const chat = chatFor(sessionId) + chat.error.set(null) + chat.streaming.set(true) + + const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] } + chat.messages.update((ms) => [...ms, userMsg]) + const assistantMsg: ChatMessage = { + id: mid(), + role: 'assistant', + text: '', + tools: [], + pendingApprovals: [] + } + chat.messages.update((ms) => [...ms, assistantMsg]) + + const activeTools: Map = new Map() + let receivedDone = false + + const controller = streamChat( + text, + sessionId, + (ev: ChatEvent) => { + if (ev.type === 'session') return // sessionId is already known for a window + if (ev.type === 'queued') { + // This message was queued behind an in-flight turn (plan 2026-08-03 + // F2): no assistant stream is attached to this response. Drop the + // optimistic empty assistant bubble so the user message is the last + // thing on screen — the thread then shows a "Queued" hint while the + // session is working, and the poller surfaces the queued turn's + // result once it runs server-side. + dropOptimisticAssistantBubble(chat.messages) + return + } + if (ev.type === 'tool_use') { + const tr: ToolCallResult = { + type: 'tool_use', + name: ev.data.name, + id: ev.data.id, + args: ev.data.args + } + activeTools.set(ev.data.id, tr) + chat.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] } + } + return [...ms] + }) + } else if (ev.type === 'tool_result') { + const existing = activeTools.get(ev.data.id) + if (existing) { + const updated: ToolCallResult = { + ...existing, + type: 'tool_result', + result: ev.data.result, + error: ev.data.error + } + activeTools.set(ev.data.id, updated) + chat.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) + ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) } + } + return [...ms] + }) + } + } else if (ev.type === 'text_delta') { + chat.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, text: last.text + ev.data } + } + return [...ms] + }) + } else if (ev.type === 'text') { + chat.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + if (ev.is_thinking) { + ms[ms.length - 1] = { + ...last, + thinking: (last.thinking || '') + ev.data, + text: '' + } + } else { + ms[ms.length - 1] = { ...last, text: ev.data } + } + } + return [...ms] + }) + } else if (ev.type === 'done') { + receivedDone = true + chat.connectionState.set('connected') + chat.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) } + } + return [...ms] + }) + startSessionPolling(sessionId) + } else if (ev.type === 'error') { + chat.error.set(humanizeChatError(ev.data)) + } + }, + (err: string) => { + if (err === 'AbortError' || err.includes('aborted')) { + chat.streaming.set(false) + return + } + // Network drop (no 'done' received): show ONE connection-lost surface + // and recover via the poller + terminal task.status event (F2/F3). + // Don't also set chat.error — the banner+toast convey it, and a raw + // "Failed to fetch" alongside would just be noise. + if (!receivedDone) { + chat.connectionState.set('disconnected') + startSessionPolling(sessionId) + addChatError( + 'Connection to the agent dropped. The task keeps running — it will catch up here automatically.', + 'Dismiss', + 'connection' + ) + } else { + // Stream ended cleanly but fetch reported an error tail — surface it. + chat.error.set(humanizeChatError(err)) + chat.streaming.set(false) + } + }, + () => { + chat.streaming.set(false) + if (activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId) + loadSessions() + } + ) + activeControllers.set(sessionId, controller) +} + +export function cancelSessionStream(sessionId: string) { + const controller = activeControllers.get(sessionId) + if (!controller) return + controller.abort() + activeControllers.delete(sessionId) + chatFor(sessionId).streaming.set(false) +} + +// ─── new-task launcher (desktop center input / Tasks app) ─────────────────── +// +// Starting a brand-new task has no session id to hang a window off of until +// the stream's own 'session' event assigns one (see the 'session' branch in +// sendMessage above) — the desktop launcher needs to open that task's window +// the moment an id exists, not before. startTask begins the stream +// immediately, buffers any events that arrive before 'session' (defensive: +// in practice 'session' always arrives first), then seeds that session's own +// chatFor() bundle exactly like sendSessionMessage does and hands the id back +// via onSession so the caller can open its window. From that point on the +// window behaves exactly like any other task window. +export function startTask(text: string, onSession: (sessionId: string) => void): void { + const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] } + const assistantMsg: ChatMessage = { + id: mid(), + role: 'assistant', + text: '', + tools: [], + pendingApprovals: [] + } + const activeTools: Map = new Map() + let receivedDone = false + let sessionId: string | null = null + let chat: SessionChatState | null = null + const buffered: ChatEvent[] = [] + + function apply(ev: ChatEvent) { + const c = chat + if (!c || !sessionId) return + if (ev.type === 'queued') { + // Defensive: a brand-new task won't normally queue (its session has no + // in-flight turn), but handle it symmetrically with sendSessionMessage — + // drop the optimistic empty assistant bubble. See plan 2026-08-03 F2. + dropOptimisticAssistantBubble(c.messages) + return + } + if (ev.type === 'tool_use') { + const tr: ToolCallResult = { + type: 'tool_use', + name: ev.data.name, + id: ev.data.id, + args: ev.data.args + } + activeTools.set(ev.data.id, tr) + c.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] } + } + return [...ms] + }) + } else if (ev.type === 'tool_result') { + const existing = activeTools.get(ev.data.id) + if (existing) { + const updated: ToolCallResult = { + ...existing, + type: 'tool_result', + result: ev.data.result, + error: ev.data.error + } + activeTools.set(ev.data.id, updated) + c.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) + ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) } + } + return [...ms] + }) + } + } else if (ev.type === 'text_delta') { + c.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, text: last.text + ev.data } + } + return [...ms] + }) + } else if (ev.type === 'text') { + c.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + if (ev.is_thinking) { + ms[ms.length - 1] = { + ...last, + thinking: (last.thinking || '') + ev.data, + text: '' + } + } else { + ms[ms.length - 1] = { ...last, text: ev.data } + } + } + return [...ms] + }) + } else if (ev.type === 'done') { + receivedDone = true + c.connectionState.set('connected') + c.messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) } + } + return [...ms] + }) + startSessionPolling(sessionId) + } else if (ev.type === 'error') { + c.error.set(humanizeChatError(ev.data)) + } + } + + const controller = streamChat( + text, + null, + (ev: ChatEvent) => { + if (ev.type === 'session') { + sessionId = ev.data + activeControllers.set(sessionId, controller) + chat = chatFor(sessionId) + chat.streaming.set(true) + chat.messages.update((ms) => [...ms, userMsg, assistantMsg]) + onSession(sessionId) + for (const b of buffered.splice(0)) apply(b) + return + } + if (!chat) { + buffered.push(ev) + return + } + apply(ev) + }, + (err: string) => { + if (!chat) return // never got a session id — nothing to show the error in + if (err === 'AbortError' || err.includes('aborted')) { + chat.streaming.set(false) + return + } + if (!receivedDone && sessionId) { + chat.connectionState.set('disconnected') + startSessionPolling(sessionId) + addChatError( + 'Connection to the agent dropped. The task keeps running — it will catch up here automatically.', + 'Dismiss', + 'connection' + ) + } else { + chat.error.set(humanizeChatError(err)) + chat.streaming.set(false) + } + }, + () => { + if (chat) chat.streaming.set(false) + if (sessionId && activeControllers.get(sessionId) === controller) + activeControllers.delete(sessionId) + loadSessions() + } + ) +} diff --git a/web/src/lib/stores/context.ts b/web/src/lib/stores/context.ts new file mode 100644 index 0000000..a067555 --- /dev/null +++ b/web/src/lib/stores/context.ts @@ -0,0 +1,64 @@ +import { writable, get } from 'svelte/store' +import { fetchDashboardSummary, fetchApprovals, type DashboardSummary } from '$lib/api' +import { liveEvents, subscribeEvents, type OikosEvent } from './events' + +// Shared operational context: dashboard summary + pending approvals, +// refreshed on a slow poll and eagerly on relevant SSE events. Ref-counted +// so the poll only runs while something on screen displays it. + +export const summary = writable(null) + +let refs = 0 +let pollTimer: ReturnType | null = null +let unsubscribeSSE: (() => void) | null = null +let unsubscribeStore: (() => void) | null = null +let lastSeenEventId = 0 + +export async function refreshContext() { + const [s] = await Promise.all([fetchDashboardSummary(), fetchApprovals('pending')]) + if (s) summary.set(s) +} + +function onEvent(ev: OikosEvent) { + if (ev.id <= lastSeenEventId) return + lastSeenEventId = ev.id + // execution.output carries no summary-level change — it just signals that a + // running command printed more. Excluded so a single noisy command doesn't + // refresh the dashboard summary once a second. + if ( + ev.type.startsWith('approval.') || + ev.type.startsWith('signal.') || + (ev.type.startsWith('execution.') && ev.type !== 'execution.output') || + ev.type === 'health.changed' + ) { + refreshContext() + } +} + +export function subscribeContext(): () => void { + refs++ + if (refs === 1) { + refreshContext() + pollTimer = setInterval(refreshContext, 30000) + unsubscribeSSE = subscribeEvents() + unsubscribeStore = liveEvents.subscribe((events) => { + if (events[0]) onEvent(events[0]) + }) + } + return () => { + refs-- + if (refs === 0) { + if (pollTimer) clearInterval(pollTimer) + pollTimer = null + unsubscribeSSE?.() + unsubscribeSSE = null + unsubscribeStore?.() + unsubscribeStore = null + } + } +} + +export function openSignalCount(s: DashboardSummary | null): number { + if (!s) return 0 + return Object.values(s.signals_by_severity).reduce((a, b) => a + b, 0) +} diff --git a/web/src/lib/stores/docked.test.ts b/web/src/lib/stores/docked.test.ts new file mode 100644 index 0000000..c74c3d1 --- /dev/null +++ b/web/src/lib/stores/docked.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// docked.ts no longer imports $lib/apps (defaults are implicit: absent key +// = visible), so no mock is needed. Each test re-imports the module fresh +// (after clearing localStorage) so the module-scoped store starts from the +// cleared state every time — without this, the store's state leaks between +// tests since it's cached with the module instance. +import type * as Docked from './docked' + +let mod: typeof Docked + +beforeEach(async () => { + localStorage.clear() + vi.resetModules() + mod = await import('./docked') +}) + +describe('docked visibility store', () => { + it('defaults docked apps to visible', () => { + expect(mod.isDockedVisible('mascot')).toBe(true) + expect(mod.isDockedVisible('other-docked')).toBe(true) + }) + + it('treats unknown ids as visible (absent key = visible)', () => { + expect(mod.isDockedVisible('nope')).toBe(true) + }) + + it('toggle flips visibility', () => { + mod.toggleDocked('mascot') + expect(mod.isDockedVisible('mascot')).toBe(false) + mod.toggleDocked('mascot') + expect(mod.isDockedVisible('mascot')).toBe(true) + }) + + it('persists to localStorage', () => { + mod.toggleDocked('mascot') + const raw = localStorage.getItem(mod.STORAGE_KEY) + expect(raw).toBeTruthy() + expect(JSON.parse(raw!).mascot).toBe(false) + }) + + it('merge over defaults so a newly-registered docked app is visible', async () => { + // Simulate a persisted blob from before 'other-docked' existed. + localStorage.setItem(mod.STORAGE_KEY, JSON.stringify({ mascot: false })) + vi.resetModules() + const fresh = await import('./docked') + expect(fresh.isDockedVisible('mascot')).toBe(false) + expect(fresh.isDockedVisible('other-docked')).toBe(true) + }) + + it('dockedVisibility store is subscribable', () => { + let latest: Record | undefined + const unsub = mod.dockedVisibility.subscribe((v) => (latest = v)) + // Store starts empty — absent keys mean visible (isDockedVisible fallback). + expect(latest).toEqual({}) + expect(mod.isDockedVisible('mascot')).toBe(true) + mod.toggleDocked('mascot') + expect(latest?.mascot).toBe(false) + expect(mod.isDockedVisible('mascot')).toBe(false) + unsub() + }) +}) diff --git a/web/src/lib/stores/docked.ts b/web/src/lib/stores/docked.ts new file mode 100644 index 0000000..7492715 --- /dev/null +++ b/web/src/lib/stores/docked.ts @@ -0,0 +1,43 @@ +// Visibility for docked apps — persisted so "hidden" survives reloads. +// Keyed by app id; an ABSENT key means visible (the default for a newly +// registered docked app, so a fresh install shows the mascot without the +// operator opting in). This means the store only holds *overrides* — it +// doesn't need to enumerate the docked apps to seed defaults, which would +// require importing APPS and create a static cycle (apps.ts -> pages -> +// windows.ts -> here -> apps.ts, TDZ on APPS at init). Unknown persisted +// keys are kept (merge semantics), so an uninstalled-then-reinstalled +// docked app remembers its visibility across the gap. +import { writable, get } from 'svelte/store' + +export const STORAGE_KEY = 'oikos-docked-apps' + +function load(): Record { + if (typeof localStorage === 'undefined') return {} + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + return JSON.parse(raw) as Record + } catch { + return {} + } +} + +const _visibility = writable>(load()) + +function persist(vis: Record): void { + if (typeof localStorage === 'undefined') return + localStorage.setItem(STORAGE_KEY, JSON.stringify(vis)) +} + +_visibility.subscribe(persist) + +// Read-only surface for components; mutations go through toggleDocked. +export const dockedVisibility = { subscribe: _visibility.subscribe } + +export function toggleDocked(appId: string): void { + _visibility.update((vis) => ({ ...vis, [appId]: !(vis[appId] ?? true) })) +} + +export function isDockedVisible(appId: string): boolean { + return get(_visibility)[appId] ?? true +} diff --git a/web/src/lib/stores/events.ts b/web/src/lib/stores/events.ts new file mode 100644 index 0000000..9f3143a --- /dev/null +++ b/web/src/lib/stores/events.ts @@ -0,0 +1,91 @@ +import { writable } from 'svelte/store' +import { sseUrl } from '$lib/config' + +export interface OikosEvent { + id: number + ts: string + type: string + entity_id?: string | null + severity: 'info' | 'warning' | 'critical' + source: string + data?: unknown + correlation_id?: string | null +} + +const MAX_BUFFERED = 200 + +export const liveEvents = writable([]) + +let source: EventSource | null = null +let subscriberCount = 0 +let retry: ReturnType | null = null +let backoff = 0 + +/** True while the stream is live. Every live surface is only as fresh as this. */ +export const eventsConnected = writable(true) + +const RETRY_BASE_MS = 1000 +const RETRY_MAX_MS = 30000 + +async function connect() { + if (source) return + // The browser's EventSource sends Last-event-ID automatically on reconnect, + // so a reconnect replays whatever was missed rather than leaving a hole. + // sseUrl is async so the OIDC access token is refreshed if expired. + const es = new EventSource(await sseUrl('/api/v1/events/stream')) + source = es + + es.onopen = () => { + backoff = 0 + eventsConnected.set(true) + } + + es.onmessage = (ev) => { + try { + const parsed: OikosEvent = JSON.parse(ev.data) + liveEvents.update((events) => [parsed, ...events].slice(0, MAX_BUFFERED)) + } catch { + // skip malformed + } + } + + // EventSource only auto-reconnects from a *transient* failure. Once it + // reaches CLOSED — which is what an HTTP error on (re)connect produces, e.g. + // the API restarting during a deploy — it stays closed and never retries. + // Leaving that to the browser meant a single blip silently froze every live + // surface in the app: health, signals and executions all just stopped + // updating, with nothing on screen to say so. That is precisely the + // stale-UI failure this whole change set exists to remove. + es.onerror = () => { + if (es.readyState !== EventSource.CLOSED) return // transient; browser retries + eventsConnected.set(false) + if (source === es) source = null + es.close() + if (subscriberCount === 0 || retry) return + backoff = backoff ? Math.min(backoff * 2, RETRY_MAX_MS) : RETRY_BASE_MS + retry = setTimeout(() => { + retry = null + if (subscriberCount > 0) connect() + }, backoff) + } +} + +function disconnect() { + if (retry) { + clearTimeout(retry) + retry = null + } + backoff = 0 + source?.close() + source = null +} + +// Reference-counted: the stream stays open as long as at least one page subscribes. +export function subscribeEvents(): () => void { + subscriberCount++ + if (subscriberCount === 1) connect() + return () => { + subscriberCount-- + if (subscriberCount === 0) disconnect() + } +} diff --git a/web/src/lib/stores/execstream.test.ts b/web/src/lib/stores/execstream.test.ts new file mode 100644 index 0000000..250b9e7 --- /dev/null +++ b/web/src/lib/stores/execstream.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { get, writable } from 'svelte/store' +import type { OikosEvent } from './events' + +// The store is driven entirely by SSE events plus a log fetch, so both are +// mocked. What matters is the correlation logic: an execution.output event is +// matched to a chat session by correlation_id, which MCP-initiated executions +// now carry (it used to be a random per-execution UUID that correlated +// nothing). + +const liveEvents = writable([]) +const subscribeEvents = vi.fn(() => () => {}) +const fetchExecutionLogs = vi.fn(async (id: string) => ({ + items: [], + combined: `output-for-${id}` +})) + +vi.mock('./events', () => ({ + liveEvents, + subscribeEvents +})) +vi.mock('$lib/api', () => ({ + fetchExecutionLogs: (id: string) => fetchExecutionLogs(id) +})) + +let mod: typeof import('./execstream') + +function event(partial: Partial): OikosEvent { + return { + id: Math.floor(Math.random() * 1e9), + ts: new Date().toISOString(), + type: 'execution.output', + entity_id: 'exec-1', + severity: 'info', + source: 'actuator', + data: {}, + correlation_id: 'session-1', + ...partial + } as OikosEvent +} + +// The store fetches asynchronously; let the microtask queue drain. +const settle = () => new Promise((r) => setTimeout(r, 0)) + +beforeEach(async () => { + liveEvents.set([]) + fetchExecutionLogs.mockClear() + vi.resetModules() + mod = await import('./execstream') +}) + +describe('liveExecutionOutputFor', () => { + it('picks up output for its own session', async () => { + const store = mod.liveExecutionOutputFor('session-1') + const stop = store.subscribe(() => {}) + + liveEvents.set([event({ entity_id: 'exec-1', correlation_id: 'session-1' })]) + await settle() + + expect(fetchExecutionLogs).toHaveBeenCalledWith('exec-1') + expect(get(store)).toEqual({ executionId: 'exec-1', output: 'output-for-exec-1' }) + stop() + }) + + // Without this every open chat window would tail every other session's + // commands. + it('ignores output belonging to a different session', async () => { + const store = mod.liveExecutionOutputFor('session-1') + const stop = store.subscribe(() => {}) + + liveEvents.set([event({ entity_id: 'exec-9', correlation_id: 'session-2' })]) + await settle() + + expect(fetchExecutionLogs).not.toHaveBeenCalled() + expect(get(store)).toBeNull() + stop() + }) + + it('ignores unrelated event types', async () => { + const store = mod.liveExecutionOutputFor('session-1') + const stop = store.subscribe(() => {}) + + liveEvents.set([event({ type: 'signal.raised' })]) + await settle() + + expect(fetchExecutionLogs).not.toHaveBeenCalled() + stop() + }) + + // A session runs commands one after another; the second must not inherit + // the first one's output. + it('resets when a new execution starts in the same session', async () => { + const store = mod.liveExecutionOutputFor('session-1') + const stop = store.subscribe(() => {}) + + liveEvents.set([event({ entity_id: 'exec-1' })]) + await settle() + expect(get(store)?.executionId).toBe('exec-1') + + liveEvents.set([event({ entity_id: 'exec-2' })]) + await settle() + expect(get(store)).toEqual({ executionId: 'exec-2', output: 'output-for-exec-2' }) + stop() + }) + + // Once the command finishes its output belongs to the tool_result, not to a + // still-"running" entry — leaving it set would show stale output against + // the next command. + it('clears on a terminal execution event', async () => { + const store = mod.liveExecutionOutputFor('session-1') + const stop = store.subscribe(() => {}) + + liveEvents.set([event({ entity_id: 'exec-1' })]) + await settle() + expect(get(store)).not.toBeNull() + + liveEvents.set([event({ type: 'execution.completed', entity_id: 'exec-1' })]) + await settle() + expect(get(store)).toBeNull() + stop() + }) + + it('does not clear on another session completing', async () => { + const store = mod.liveExecutionOutputFor('session-1') + const stop = store.subscribe(() => {}) + + liveEvents.set([event({ entity_id: 'exec-1' })]) + await settle() + + liveEvents.set([ + event({ type: 'execution.completed', entity_id: 'exec-5', correlation_id: 'session-2' }) + ]) + await settle() + expect(get(store)).not.toBeNull() + stop() + }) +}) diff --git a/web/src/lib/stores/execstream.ts b/web/src/lib/stores/execstream.ts new file mode 100644 index 0000000..8c887d9 --- /dev/null +++ b/web/src/lib/stores/execstream.ts @@ -0,0 +1,102 @@ +// Live command output for a chat session's currently-running execution. +// +// The chat renders a `run` tool call as "running" from the moment the tool_use +// arrives until its tool_result comes back — and for an auto-run that gap IS +// the command's runtime. Until now that window showed only the arguments; the +// output appeared all at once at the end. +// +// Correlation works because MCP-initiated executions now carry the chat +// session id as their correlation_id, and every execution.output event carries +// that through. So an event can be matched to the session on screen without a +// lookup. Nomos runs tools sequentially, so at most one execution is in flight +// per session — no ambiguity about which entry the output belongs to. +// +// Only auto-runs stream. A gated run returns "execution queued" immediately, +// so its tool entry is already `done` and the command executes minutes later +// after approval — the entity detail window is where that one is watched. + +import { readable, type Readable } from 'svelte/store' +import { liveEvents, subscribeEvents } from './events' +import { fetchExecutionLogs } from '$lib/api' + +export interface LiveExecutionOutput { + executionId: string + output: string +} + +const cache = new Map>() + +/** + * Live output for whichever execution this session is currently running. + * Resets when a different execution starts, so output from a previous command + * never bleeds into the next one's entry. + */ +export function liveExecutionOutputFor(sessionId: string): Readable { + const existing = cache.get(sessionId) + if (existing) return existing + + const store = readable(null, (set) => { + let currentId: string | null = null + let inFlight = false + // Coalesce: a refetch already running means the next event's data will be + // covered by a single follow-up, rather than queueing a request per event. + let queued = false + + async function refresh(executionId: string) { + if (inFlight) { + queued = true + return + } + inFlight = true + try { + const logs = await fetchExecutionLogs(executionId) + // Drop a response for an execution that is no longer current. + if (currentId === executionId) set({ executionId, output: logs.combined }) + } finally { + inFlight = false + if (queued) { + queued = false + if (currentId) refresh(currentId) + } + } + } + + const unsubscribeSSE = subscribeEvents() + const unsubscribeEvents = liveEvents.subscribe((events) => { + const ev = events[0] + if (!ev) return + + // Lifecycle end: clear so the finished command's output stops being + // shown against a new "running" entry. + if ( + ev.correlation_id === sessionId && + (ev.type === 'execution.completed' || + ev.type === 'execution.failed' || + ev.type === 'execution.cancelled') + ) { + currentId = null + set(null) + return + } + + if (ev.type !== 'execution.output') return + if (ev.correlation_id !== sessionId) return + if (!ev.entity_id) return + + if (ev.entity_id !== currentId) { + currentId = ev.entity_id + set({ executionId: currentId, output: '' }) + } + refresh(ev.entity_id) + }) + + return () => { + unsubscribeEvents() + unsubscribeSSE() + cache.delete(sessionId) + } + }) + + cache.set(sessionId, store) + return store +} diff --git a/web/src/lib/stores/icons.test.ts b/web/src/lib/stores/icons.test.ts new file mode 100644 index 0000000..b16f450 --- /dev/null +++ b/web/src/lib/stores/icons.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// icons.ts reads the reactive `apps` store (derived from built-ins + +// installed apps) to seed default positions. Stub it as a minimal Readable +// emitting a fixed list rather than pull in the real registry. +vi.mock('$lib/apps', () => { + const list = [ + { id: 'tasks' }, + { id: 'kb' }, + { id: 'ops' }, + { id: 'signals' }, + { id: 'knowledge' }, + { id: 'learning' } + ] + return { + apps: { + subscribe: (cb: (v: typeof list) => void) => { + cb(list) + return () => {} + } + } + } +}) + +import { + GRID, + iconPositions, + placeIcon, + iconPixelPos, + maxCols, + getIconPositions, + resetIconLayout +} from './icons' + +// placeIcon mutates the shared module-level store, so each test starts from +// a known, empty layout rather than whatever the previous test (or apps.ts's +// registry-derived defaults) left behind. +beforeEach(() => { + iconPositions.set({}) + localStorage.clear() +}) + +describe('iconPixelPos', () => { + it('converts a grid cell to pixel coordinates using GRID constants', () => { + expect(iconPixelPos({ col: 0, row: 0 })).toEqual({ x: GRID.padding, y: GRID.padding }) + expect(iconPixelPos({ col: 1, row: 2 })).toEqual({ + x: GRID.padding + (GRID.cell + GRID.gap), + y: GRID.padding + 2 * (GRID.cell + GRID.gap) + }) + }) +}) + +describe('maxCols', () => { + it('computes how many columns fit in a viewport width', () => { + const cellSpan = GRID.cell + GRID.gap + expect(maxCols(GRID.padding + cellSpan * 3)).toBe(3) + }) + + it('never returns less than 1, even for a tiny viewport', () => { + expect(maxCols(0)).toBe(1) + expect(maxCols(GRID.padding)).toBe(1) + }) +}) + +describe('placeIcon', () => { + it('places an icon at the requested cell when it is free', () => { + placeIcon('tasks', 3, 4) + expect(getIconPositions().tasks).toEqual({ col: 3, row: 4 }) + }) + + it('clamps negative coordinates to 0 for an otherwise-free cell', () => { + placeIcon('tasks', -5, -2) + expect(getIconPositions().tasks).toEqual({ col: 0, row: 0 }) + }) + + it('nudges to the nearest free cell when the target is occupied', () => { + iconPositions.set({ kb: { col: 2, row: 2 } }) + placeIcon('tasks', 2, 2) + const pos = getIconPositions().tasks + // Must not land on top of kb, and must be one of the 8 immediate + // neighbors (radius-1 ring) since all of them are free. + expect(pos).not.toEqual({ col: 2, row: 2 }) + expect(Math.max(Math.abs(pos.col - 2), Math.abs(pos.row - 2))).toBe(1) + }) + + it('does not disturb the icon already occupying a cell when another icon is nudged past it', () => { + iconPositions.set({ kb: { col: 2, row: 2 } }) + placeIcon('tasks', 2, 2) + expect(getIconPositions().kb).toEqual({ col: 2, row: 2 }) + }) + + it('moving an icon back onto its own current cell is a no-op collision (never nudges against itself)', () => { + iconPositions.set({ tasks: { col: 5, row: 5 } }) + placeIcon('tasks', 5, 5) + expect(getIconPositions().tasks).toEqual({ col: 5, row: 5 }) + }) + + it('persists the updated layout to localStorage', () => { + placeIcon('tasks', 1, 1) + const stored = JSON.parse(localStorage.getItem('oikos-desktop-icons') ?? '{}') + expect(stored.tasks).toEqual({ col: 1, row: 1 }) + }) +}) + +describe('resetIconLayout', () => { + it('restores the classic left-edge column in registry order', () => { + iconPositions.set({ tasks: { col: 4, row: 7 }, kb: { col: 1, row: 1 } }) + resetIconLayout() + expect(getIconPositions()).toEqual({ + tasks: { col: 0, row: 0 }, + kb: { col: 0, row: 1 }, + ops: { col: 0, row: 2 }, + signals: { col: 0, row: 3 }, + knowledge: { col: 0, row: 4 }, + learning: { col: 0, row: 5 } + }) + }) +}) diff --git a/web/src/lib/stores/icons.ts b/web/src/lib/stores/icons.ts new file mode 100644 index 0000000..3267e69 --- /dev/null +++ b/web/src/lib/stores/icons.ts @@ -0,0 +1,140 @@ +// Desktop icon positions — a simple column/row grid, persisted to +// localStorage so icons stay where the operator put them across reloads. +// Deliberately NOT wmkit: wmkit manages floating windows (pixel bounds, +// z-order, stage), icons are a flat, non-overlapping grid with a much +// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing +// that inside wmkit would mean fighting its window-shaped abstractions for +// no benefit. +// +// Reactive to the apps store (Phase 3): when an installed app registers +// after init, it gets a free cell on the next emission. Uninstalled apps' +// positions are KEPT (so reinstall remembers where the icon was) but their +// icons simply don't render — the desktop's {#each $apps} is the source of +// truth for what's visible. +import { writable, get } from 'svelte/store' +import { apps } from '$lib/apps' + +export interface IconPos { + col: number + row: number +} + +export const GRID = { cell: 96, gap: 12, padding: 16 } + +const STORAGE_KEY = 'oikos-desktop-icons' + +function load(): Record { + if (typeof localStorage === 'undefined') return {} + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + return JSON.parse(raw) as Record + } catch { + return {} + } +} + +export const iconPositions = writable>(load()) + +function persist(positions: Record): void { + if (typeof localStorage === 'undefined') return + localStorage.setItem(STORAGE_KEY, JSON.stringify(positions)) +} + +iconPositions.subscribe(persist) + +function occupied( + positions: Record, + col: number, + row: number, + exceptId: string +): boolean { + return Object.entries(positions).some( + ([id, p]) => id !== exceptId && p.col === col && p.row === row + ) +} + +// Finds the nearest free cell to (col, row) via an expanding ring search, +// so dropping an icon onto an occupied cell nudges it to the closest open +// spot instead of silently overlapping or refusing the drop. +function nearestFreeCell( + positions: Record, + col: number, + row: number, + exceptId: string +): IconPos { + if (!occupied(positions, col, row, exceptId)) + return { col: Math.max(0, col), row: Math.max(0, row) } + for (let radius = 1; radius < 64; radius++) { + for (let dc = -radius; dc <= radius; dc++) { + for (let dr = -radius; dr <= radius; dr++) { + if (Math.max(Math.abs(dc), Math.abs(dr)) !== radius) continue + const c = col + dc + const r = row + dr + if (c < 0 || r < 0) continue + if (!occupied(positions, c, r, exceptId)) return { col: c, row: r } + } + } + } + return { col: Math.max(0, col), row: Math.max(0, row) } +} + +export function placeIcon(appId: string, col: number, row: number): void { + iconPositions.update((positions) => { + const target = nearestFreeCell(positions, col, row, appId) + return { ...positions, [appId]: target } + }) +} + +export function iconPixelPos(pos: IconPos): { x: number; y: number } { + return { + x: GRID.padding + pos.col * (GRID.cell + GRID.gap), + y: GRID.padding + pos.row * (GRID.cell + GRID.gap) + } +} + +export function maxCols(viewportWidth: number): number { + return Math.max(1, Math.floor((viewportWidth - GRID.padding) / (GRID.cell + GRID.gap))) +} + +export function getIconPositions(): Record { + return get(iconPositions) +} + +// Bails a messy manual layout back to the classic left-edge column, +// registry order — the desktop's right-click menu's "Reset icon layout". +// Clears all positions then re-seeds from the current app list, so it +// respects the live registry (including installed apps) rather than a +// static snapshot. +export function resetIconLayout(): void { + iconPositions.set(seedMissing({}, get(apps))) +} + +// Seeds positions for any app in `list` that doesn't have one yet — +// classic OS default: one left-edge column, registry order. Returns the +// same positions object if nothing needed seeding (so callers can skip a +// no-op set), otherwise a fresh merged object. +function seedMissing( + positions: Record, + list: { id: string }[] +): Record { + let next: Record | null = null + let row = 0 + for (const app of list) { + if (positions[app.id]) continue + if (!next) next = { ...positions } + while (occupied(next, 0, row, app.id)) row++ + next![app.id] = { col: 0, row } + row++ + } + return next ?? positions +} + +// Seed on every apps-store emission so a newly-installed app gets a cell +// immediately. Existing positions (including uninstalled apps' remembered +// spots) are preserved. +apps.subscribe((list) => { + const current = get(iconPositions) + const next = seedMissing(current, list) + if (next !== current) iconPositions.set(next) +}) diff --git a/web/src/lib/stores/theme.svelte.ts b/web/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..3dba711 --- /dev/null +++ b/web/src/lib/stores/theme.svelte.ts @@ -0,0 +1,47 @@ +export type Theme = 'light' | 'dark' + +const STORAGE_KEY = 'oikos-theme' + +function applyClass(theme: Theme): void { + const root = document.documentElement + if (theme === 'dark') { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } +} + +function storedTheme(): Theme { + if (typeof localStorage === 'undefined') return 'dark' + const v = localStorage.getItem(STORAGE_KEY) + if (v === 'light' || v === 'dark') return v + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark' +} + +const initialTheme = storedTheme() +let current: Theme = $state(initialTheme) + +applyClass(initialTheme) + +export function setTheme(t: Theme): void { + current = t + applyClass(t) + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, t) + } +} + +export function getTheme(): Theme { + return current +} + +export function toggleTheme(): Theme { + const next = current === 'dark' ? 'light' : 'dark' + setTheme(next) + return next +} + +export const THEME_LABELS: Record = { + light: 'Light', + dark: 'Dark' +} diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts new file mode 100644 index 0000000..a9c5167 --- /dev/null +++ b/web/src/lib/stores/windows.ts @@ -0,0 +1,213 @@ +// One global wmkit window manager for the whole app (mounted once by +// WindowLayer.svelte inside Desktop.svelte) — this is what lets an entity +// opened from Knowledge Base, chat, or anywhere else land in the same +// floating window layer, with several windows open side by side, rather than +// each page owning its own single-entity sidebar/sheet. +import { derived, get, type Readable } from 'svelte/store' +import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte' +import { persist } from '@surdeddd/wmkit/persist' +import { appById, appWindowId } from '$lib/apps' +import { toggleDocked } from '$lib/stores/docked' +import { sessions } from '$lib/stores/chat' +import { heading } from '$lib/tasks' + +// Window ids for a task/session's chat window are namespaced `session:` +// — see openTaskWindow below. Shared here (rather than each file redeclaring +// its own copy) since both WindowLayer.svelte and focusedSessionId below +// need to parse it. +export const SESSION_PREFIX = 'session:' + +export const wm = createManager({ defaultSize: { width: 480, height: 560 } }) + +// New windows (and manual resizing) must never exceed the visible desktop +// area — without this, a content-heavy entity window (many Details/ +// Relations/Tasks sections) grows taller than the viewport with no way to +// reach its own titlebar controls. Clamps requested width/height down to +// the current viewport and caps maxWidth/maxHeight the same way, so +// dragging a resize handle can't push it past the edge either. +function clampToDesktop< + T extends { width?: number; height?: number; maxWidth?: number; maxHeight?: number } +>(init: T): T { + const { viewport } = wm.getState() + if (viewport.width <= 0 || viewport.height <= 0) return init + return { + ...init, + width: init.width !== undefined ? Math.min(init.width, viewport.width) : undefined, + height: init.height !== undefined ? Math.min(init.height, viewport.height) : undefined, + maxWidth: Math.min(init.maxWidth ?? viewport.width, viewport.width), + maxHeight: Math.min(init.maxHeight ?? viewport.height, viewport.height) + } +} +export const dk = createDesktop(wm, { + // topEdge:'maximize' + preview gives the classic drag-to-top-maximizes + // affordance; magnetism/keyboard are wmkit defaults worth turning on now + // that windows are the whole app's primary surface, not a secondary layer. + snap: { topEdge: 'maximize', preview: true }, + keyboard: true, + magnetism: true, + // Animates a minimized window toward its taskbar button instead of just + // vanishing — Taskbar.svelte tags each button with this same attribute. + minimizeTarget: (win) => document.querySelector(`[data-taskbar-btn="${CSS.escape(win.id)}"]`) +}) +export const wmState = wmStore(wm) + +// Stable insertion-order window IDs — unlike $wmState.order (which reorders on +// focus/raise), this only changes when a window is opened or closed. Used by +// WindowLayer's {#each} so the DOM order stays stable; wmkit handles visual +// stacking via z-index in syncAll(). Without this, every focus change moves +//
elements in the DOM, which resets scroll positions of scrollable +// children in Chrome. +let _lastKeys: string[] = [] +export const windowKeys = derived(wmState, ($s) => { + const keys = Object.keys($s.windows) + if (keys.length === _lastKeys.length && keys.every((k, i) => k === _lastKeys[i])) { + return _lastKeys + } + _lastKeys = keys + return keys +}) + +// The session id backing whichever task/chat window currently has focus, or +// null when no task window is focused (Tasks app, an entity window, or +// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so +// its reactions track the task the operator is actually looking at, rather +// than firing for every session fleet-wide. +export const focusedSessionId: Readable = derived(wmState, ($s) => + $s.focusedId?.startsWith(SESSION_PREFIX) ? $s.focusedId.slice(SESSION_PREFIX.length) : null +) + +// Layout survives reloads: every window id is self-describing (app:, +// session:, or a bare entity slug — see EntityDesktop/WindowLayer's +// content branch), so a hydrated window needs no extra bookkeeping to know +// what to render once the desktop remounts. +export const wmPersist = persist(wm, { key: 'oikos-windows', debounce: 300, autoRestore: true }) + +// A task window is titled from the operator's (truncated) prompt at +// creation time — openTaskWindow below and chat.ts's startTask both only +// know the raw text, not the goal/heading the backend eventually derives +// for the session. Whenever the sessions list refreshes (loadSessions(), +// called all over — on task events, after a turn completes, ...) resync +// any open task window's title to the session's real heading, so the +// taskbar/titlebar stop showing the placeholder forever. +sessions.subscribe((list) => { + for (const s of list) { + const id = `session:${s.id}` + const win = wm.get(id) + if (!win) continue + const title = heading(s) + if (win.title !== title) wm.update(id, { title }) + } +}) + +// Classic show-desktop toggle: minimize everything, or if everything's +// already minimized (a prior show-desktop, or the operator minimized them +// all by hand), bring them all back rather than being a one-way action. +// Shared by the taskbar button and the desktop's right-click menu. +export function toggleShowDesktop(): void { + const anyVisible = wm.getState().order.some((id) => wm.get(id)?.stage !== 'minimized') + if (anyVisible) wm.minimizeAll() + else wm.restoreAll() +} + +// Opens (or focuses/restores) a registry app's window. Apps are +// single-instance — double-clicking an already-open app's icon should never +// stack a second window, same dedupe pattern as openEntityWindow below. +// Docked apps (e.g. the mascot) have no wmkit window at all — clicking their +// icon toggles visibility on the Docked Layer instead, so this branches on +// kind before touching the window manager. All callers (the desktop icon, +// the taskbar settings button, legacy hash resolution) go through here, so +// none of them need a kind-specific branch. +export function openAppWindow(appId: string): void { + const app = get(appById).get(appId) + if (!app) return + if (app.docked) { + toggleDocked(appId) + return + } + const id = appWindowId(appId) + if (wm.get(id)) { + wm.restore(id) + wm.focus(id) + return + } + wm.open( + clampToDesktop({ + id, + title: app.title, + width: app.width, + height: app.height, + minWidth: app.minWidth, + minHeight: app.minHeight + }) + ) +} + +// Opens a window for the entity, or focuses (and restores, if minimized) the +// existing one — wm.open() throws if a window with this id already exists, +// and slugs make natural, stable window ids (also dedupes "same entity +// opened twice" into one window instead of stacking duplicates). +export function openEntityWindow(slug: string | null): void { + if (!slug) return + if (wm.get(slug)) { + wm.restore(slug) + wm.focus(slug) + return + } + wm.open(clampToDesktop({ id: slug, title: slug })) +} + +// Singleton "compose a new task" window — the Tasks app's New Task button +// opens this rather than a dialog, since everything else in the desktop is +// already a window. It renders as an empty chat (NewTaskChat, in +// WindowLayer.svelte) sized like a real task window rather than a separate +// compose screen, and closes itself once the task's session window takes +// over. +export const NEW_TASK_WINDOW_ID = 'new-task' + +// Seed text for the next new-task window. Module state rather than a window +// property because wmkit windows carry only geometry — WindowLayer reads this +// when it mounts NewTaskChat. Cleared on read so a later manually-opened task +// does not inherit a stale prompt. +let pendingTaskDraft = '' + +export function takePendingTaskDraft(): string { + const draft = pendingTaskDraft + pendingTaskDraft = '' + return draft +} + +export function openNewTaskWindow(draft = ''): void { + pendingTaskDraft = draft + if (wm.get(NEW_TASK_WINDOW_ID)) { + wm.restore(NEW_TASK_WINDOW_ID) + wm.focus(NEW_TASK_WINDOW_ID) + return + } + wm.open( + clampToDesktop({ + id: NEW_TASK_WINDOW_ID, + title: 'New task', + width: 900, + height: 640, + minWidth: 600, + minHeight: 400 + }) + ) +} + +// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's +// chat window. Id is namespaced `session:` — distinct from entity window +// ids (always a bare `type:identifier` slug, and task ENTITIES already use +// `task:` as their own slug) so a task's chat window and its entity +// detail window never collide over the same wmkit id. See +// WindowLayer.svelte for the id -> content-component branch. +export function openTaskWindow(sessionId: string | null, title: string): void { + if (!sessionId) return + const id = `session:${sessionId}` + if (wm.get(id)) { + wm.restore(id) + wm.focus(id) + return + } + wm.open(clampToDesktop({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 })) +} diff --git a/web/src/lib/stores/workspace.ts b/web/src/lib/stores/workspace.ts new file mode 100644 index 0000000..b9d3828 --- /dev/null +++ b/web/src/lib/stores/workspace.ts @@ -0,0 +1,408 @@ +import { writable, derived, get, type Writable, type Readable } from 'svelte/store' +import { liveEvents, subscribeEvents } from './events' +import { currentSession, sessions, loadSessions, chatFor, streaming } from './chat' +import { + fetchPlan, + fetchQuestions, + type PlanStep, + type SessionQuestion, + type Session +} from '$lib/api' +import type { + PlanProposedData, + PlanStepEventData, + QuestionRaisedData, + QuestionAnsweredData, + EntityTouchedData, + HealthChangedData +} from '$lib/types' + +// workspace.ts is the live "what is this task doing right now" surface for the +// TaskContextPanel: plan progress, the pinned operator question, and entities +// the agent is touching or whose health just changed. It is deliberately driven +// by the ALWAYS-ON global events stream (subscribeEvents), not the per-turn +// chat SSE — the auto-continuation worker and resumeSession run entirely +// server-side with no chat turn open, so a chat-bound panel would go stale +// exactly when the agent is working autonomously. This also means the panel +// keeps updating across a tab reload: hydrate() re-fetches REST state, then +// live events carry deltas from there. + +export interface TouchedEntity { + slug: string + tool: string + ts: number +} +const TOUCHED_MAX = 12 +const TOUCHED_PULSE_MS = 6000 + +export interface HealthDiff { + slug: string + from: string + to: string + ts: number +} +const HEALTH_DIFF_MS = 8000 + +export interface WorkspaceState { + planSteps: Writable + questions: Writable + openQuestion: Readable + touched: Writable + healthDiffs: Writable +} + +function createWorkspaceState(): WorkspaceState { + const questions = writable([]) + return { + planSteps: writable([]), + questions, + openQuestion: derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null), + touched: writable([]), + healthDiffs: writable([]) + } +} + +// ─── global "current session" workspace — used by the main Chat page's rail ─ +const globalWorkspace = createWorkspaceState() +export const planSteps = globalWorkspace.planSteps +export const questions = globalWorkspace.questions +export const openQuestion = globalWorkspace.openQuestion +export const touched = globalWorkspace.touched +export const healthDiffs = globalWorkspace.healthDiffs + +// The task's own fields (goal/status/outcome/summary) live on the session row. +// Rather than a dedicated endpoint, derive from the sessions list (already +// fetched for the task board) and keep it fresh here on task-lifecycle events. +export const currentTask = derived( + [sessions, currentSession], + ([$sessions, $id]) => $sessions.find((s) => s.id === $id) ?? null +) + +// Events that can change agent_sessions.status/goal/outcome — see applyEventTo. +const STATUS_AFFECTING = new Set([ + 'goal.set', + 'task.status', + 'plan.proposed', + 'question.raised', + 'question.answered' +]) + +let refreshTimer: ReturnType | null = null +function scheduleSessionsRefresh() { + if (refreshTimer) clearTimeout(refreshTimer) + refreshTimer = setTimeout(() => loadSessions(), 300) +} + +// A dropped plan-step event is one that matched no step on screen — +// historically a silent return, which made a disagreeing backend look like a +// dead UI (the panel froze showing pending steps while work happened +// elsewhere). Surface it so the next divergence is visible. Exported so a +// debug surface (or a test) can read the count. +export let droppedPlanStepEvents = 0 +export function resetDroppedPlanStepEvents(): void { + droppedPlanStepEvents = 0 +} + +function applyPlanStepEventTo(ws: WorkspaceState, data: PlanStepEventData) { + const stepID = data?.step_id + const seq = data?.seq + ws.planSteps.update((steps) => { + const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq)) + if (i === -1) { + droppedPlanStepEvents++ + console.warn('plan step event matched no step on screen', { + stepID, + seq, + status: data?.status + }) + return steps + } + const next = [...steps] + next[i] = { + ...next[i], + status: data.status ?? next[i].status, + execution_id: data.execution_id ?? next[i].execution_id + } + return next + }) +} + +// Applies a live event to `ws` if it belongs to session `sid` — shared by the +// global "current session" watcher and every per-session floating-window +// watcher, each passing its own target state and session id. +function applyEventTo( + ws: WorkspaceState, + sid: string, + ev: { type: string; correlation_id?: string | null; data?: unknown } +) { + if (ev.correlation_id !== sid) return + const data = (ev.data ?? {}) as Record + + // Task fields (status/goal/outcome) live on the session row — refetch the + // (cheap) session list so the UI picks up the change without a + // dedicated endpoint. Every event that can change agent_sessions.status + // (goal.set → planning, propose_plan → executing, ask_operator → + // awaiting_input, answerQuestion → executing, complete_task → done/failed) + // must trigger this, not just goal.set/task.status — otherwise the status + // pill goes stale exactly when resumeSession runs the next turn entirely + // server-side, with no client-streaming 'done' event to piggyback a refresh + // on (found live: answering a question via the panel left the header stuck + // on "Needs your input" after the agent had already resumed). Debounced + // since several of these can land in one burst, and shared across + // sessions since it just refreshes the one global session list. + if (STATUS_AFFECTING.has(ev.type)) scheduleSessionsRefresh() + + switch (ev.type) { + case 'plan.proposed': { + const d = data as unknown as PlanProposedData + if (Array.isArray(d.steps)) { + const incoming = d.steps.map((s) => ({ + id: s.id, + seq: s.seq, + title: s.title, + detail: s.detail ?? '', + status: 'pending' as const, + target_slug: s.target_slug || undefined + })) + ws.planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming)) + } + break + } + case 'plan.step.started': + case 'plan.step.finished': + applyPlanStepEventTo(ws, data as unknown as PlanStepEventData) + break + case 'question.raised': { + const d = data as unknown as QuestionRaisedData + ws.questions.update((qs) => [ + { + id: d.question_id, + prompt: d.prompt ?? '', + context: { why: d.why, options: d.options, entities: d.entities }, + status: 'open', + created_at: new Date().toISOString() + }, + ...qs.filter((q) => q.id !== d.question_id) + ]) + break + } + case 'question.answered': { + const d = data as unknown as QuestionAnsweredData + ws.questions.update((qs) => + qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q)) + ) + break + } + case 'entity.touched': { + const d = data as unknown as EntityTouchedData + if (d.slug) { + const now = Date.now() + ws.touched.update((t) => + [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX) + ) + } + break + } + case 'knowledge.recorded': + break + } +} + +// health.changed is task-agnostic (fleet-wide), so it's matched separately: +// show the diff whenever the changed entity is one this task has touched, not +// by correlation_id (health events don't carry one). +function applyHealthChangedTo(ws: WorkspaceState, ev: { type: string; data?: unknown }) { + if (ev.type !== 'health.changed') return + const data = (ev.data ?? {}) as HealthChangedData + if (!data.slug) return + const isRelevant = get(ws.touched).some((t) => t.slug === data.slug) + if (!isRelevant) return + ws.healthDiffs.update((d) => + [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice( + 0, + 8 + ) + ) +} + +let hydratedFor: string | null = null +let unsubStream: (() => void) | null = null +let unsubLive: (() => void) | null = null +let lastSeenId = 0 + +async function hydrate(sessionId: string) { + hydratedFor = sessionId + globalWorkspace.planSteps.set([]) + globalWorkspace.questions.set([]) + globalWorkspace.touched.set([]) + globalWorkspace.healthDiffs.set([]) + const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)]) + if (get(currentSession) !== sessionId) return // switched away while loading + globalWorkspace.planSteps.set(steps) + globalWorkspace.questions.set(qs) +} + +// startWorkspace opens the global event subscription and begins tracking the +// active session. Call once from the panel's onMount; call the returned +// cleanup on unmount. Safe to call multiple times (ref-counted underneath). +export function startWorkspace(): () => void { + unsubStream = subscribeEvents() + + const unsubSession = currentSession.subscribe((sid) => { + if (sid && sid !== hydratedFor) hydrate(sid) + if (!sid) { + hydratedFor = null + globalWorkspace.planSteps.set([]) + globalWorkspace.questions.set([]) + globalWorkspace.touched.set([]) + globalWorkspace.healthDiffs.set([]) + } + }) + + unsubLive = liveEvents.subscribe((evs) => { + if (evs.length === 0) return + const maxId = evs[0].id + if (maxId <= lastSeenId) { + return + } + const fresh = evs.filter((e) => e.id > lastSeenId) + lastSeenId = maxId + const sid = get(currentSession) + if (!sid) return + // Oldest-first application so ordering (e.g. plan.step.started before + // .finished) is preserved. + let planAffecting = false + for (const e of fresh.slice().reverse()) { + applyEventTo(globalWorkspace, sid, e) + applyHealthChangedTo(globalWorkspace, e) + if (e.correlation_id === sid && STATUS_AFFECTING.has(e.type)) planAffecting = true + } + if (planAffecting) schedulePlanRefetch(globalWorkspace, sid) + }) + + return () => { + unsubSession() + unsubLive?.() + unsubStream?.() + } +} + +// ─── per-session workspace, for floating task windows ─────────────────────── +// +// Same shape as the global workspace above, but keyed by session id instead +// of "whatever's on screen" — mirrors chat.ts's chatFor(). A window's +// TaskContextPanel calls startSessionWorkspace(sessionId) instead of +// startWorkspace(), and reads workspaceFor(sessionId)'s stores instead of the +// global ones, so several sessions' panels can be open and live at once. +const workspaces = new Map() + +export function workspaceFor(sessionId: string): WorkspaceState { + let w = workspaces.get(sessionId) + if (!w) { + w = createWorkspaceState() + workspaces.set(sessionId, w) + } + return w +} + +export function taskFor(sessionId: string): Readable { + return derived(sessions, ($sessions) => $sessions.find((s) => s.id === sessionId) ?? null) +} + +// Session statuses where the server is actively running a turn for this task. +// Deliberately EXCLUDES `awaiting_input` (paused for the operator) and the +// terminal states (done/failed/abandoned). This is the reliable "the agent is +// working" truth that survives a dropped SSE stream or an autonomous/background +// turn (which has no chat stream at all) — see plan 2026-08-03 F1. +const ACTIVE_TURN_STATUS = new Set(['planning', 'executing']) + +function isWorking($streaming: boolean, $task: Session | null): boolean { + return $streaming || (!!$task && !!$task.status && ACTIVE_TURN_STATUS.has($task.status)) +} + +// taskWorking(sessionId): true while this session has a live stream OR its +// server-side status says a turn is running. Used by the chat window's +// "working" indicator, trace running state, and the activity spinner so a +// background/long/desynced turn still looks alive (the symptom: "can't tell +// the agent is working"). +export function taskWorking(sessionId: string): Readable { + const chat = chatFor(sessionId) + return derived([chat.streaming, taskFor(sessionId)], ([$s, $t]) => isWorking($s, $t)) +} + +// Global "current session" working signal for the main view's panel. +export const currentWorking = derived( + [streaming, currentTask], + ([$s, $t]) => isWorking($s, $t) +) + +async function hydrateSession(ws: WorkspaceState, sessionId: string) { + const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)]) + ws.planSteps.set(steps) + ws.questions.set(qs) +} + +// Self-heal for the plan panel (plan 2026-08-03 F4): plan steps are otherwise +// driven ONLY by live plan.proposed/plan.step.* events plus a one-time hydrate +// on mount. If an event is missed (window opened mid-turn, a brief events- +// stream gap), the panel freezes on a stale generation. Refetching the plan +// (current generation) on any task-lifecycle event makes it converge back to +// truth. Debounced per session since several of these land in one burst. +const planRefreshTimers = new Map>() +function schedulePlanRefetch(ws: WorkspaceState, sessionId: string) { + const existing = planRefreshTimers.get(sessionId) + if (existing) clearTimeout(existing) + planRefreshTimers.set( + sessionId, + setTimeout(async () => { + planRefreshTimers.delete(sessionId) + try { + ws.planSteps.set(await fetchPlan(sessionId)) + } catch { + // network blip — the next lifecycle event retries + } + }, 400) + ) +} + +export function startSessionWorkspace(sessionId: string): () => void { + const ws = workspaceFor(sessionId) + const unsub = subscribeEvents() + hydrateSession(ws, sessionId) + + // Own "seen" watermark rather than the global lastSeenId — several + // windows, each watching a different session, can be reading off the same + // liveEvents feed at once. + let lastSeen = 0 + const unsubLive = liveEvents.subscribe((evs) => { + if (evs.length === 0) return + const maxId = evs[0].id + if (maxId <= lastSeen) return + const fresh = evs.filter((e) => e.id > lastSeen) + lastSeen = maxId + let planAffecting = false + for (const e of fresh.slice().reverse()) { + applyEventTo(ws, sessionId, e) + applyHealthChangedTo(ws, e) + if (e.correlation_id === sessionId && STATUS_AFFECTING.has(e.type)) planAffecting = true + } + if (planAffecting) schedulePlanRefetch(ws, sessionId) + }) + + return () => { + unsubLive() + unsub() + } +} + +// Sweep expired pulses/diffs on an interval so old touches stop glowing — +// across the global workspace and every per-session one currently in use. +setInterval(() => { + const now = Date.now() + const sweep = (ws: WorkspaceState) => { + ws.touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS)) + ws.healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS)) + } + sweep(globalWorkspace) + for (const ws of workspaces.values()) sweep(ws) +}, 1000) diff --git a/web/src/lib/tasks.ts b/web/src/lib/tasks.ts new file mode 100644 index 0000000..1f95ff4 --- /dev/null +++ b/web/src/lib/tasks.ts @@ -0,0 +1,62 @@ +// Shared task-status helpers. Used by the Overview homepage (and previously the +// standalone Tasks board) to map a session's lifecycle onto a small set of +// display buckets, styles, and filters. +import type { Session } from '$lib/api' + +export type Bucket = 'running' | 'input' | 'done' | 'failed' + +export function bucket(s: Session): Bucket { + switch (s.status) { + case 'awaiting_input': + return 'input' + case 'done': + return s.outcome === 'failure' ? 'failed' : 'done' + case 'failed': + return 'failed' + default: + return 'running' // active | planning | executing | undefined + } +} + +export interface StatusStyle { + label: string + dot: string + pulse: boolean +} + +export function statusStyle(s: Session): StatusStyle { + switch (bucket(s)) { + case 'input': + return { label: 'Needs input', dot: 'bg-warning', pulse: true } + case 'done': + return { + label: s.outcome === 'partial' ? 'Done · partial' : 'Done', + dot: 'bg-success', + pulse: false + } + case 'failed': + return { label: 'Failed', dot: 'bg-destructive', pulse: false } + default: + return { label: 'Running', dot: 'bg-primary', pulse: true } + } +} + +export const FILTERS: { id: 'all' | Bucket; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'running', label: 'Running' }, + { id: 'input', label: 'Needs input' }, + { id: 'done', label: 'Done' }, + { id: 'failed', label: 'Failed' } +] + +// Task events that should trigger a session-board refetch. +export const TASK_EVENTS = new Set([ + 'task.status', + 'goal.set', + 'question.raised', + 'question.answered' +]) + +export function heading(s: Session): string { + return s.goal || s.title || 'Untitled task' +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..a4f23d8 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,136 @@ +// Discriminated unions for SSE event payloads — eliminates `any` in the +// chat and workspace stores by giving each event type a concrete data shape. + +// ---- Chat SSE events (streamChat / /agent/chat) ---- + +export interface ChatSessionEvent { + type: 'session' + data: string +} + +export interface ChatToolUseEvent { + type: 'tool_use' + data: { name: string; id: string; args: Record } +} + +export interface ChatToolResultEvent { + type: 'tool_result' + data: { id: string; result: unknown; error?: string } +} + +export interface ChatTextDeltaEvent { + type: 'text_delta' + data: string +} + +export interface ChatTextEvent { + type: 'text' + data: string + is_thinking?: boolean +} + +export interface ChatDoneEvent { + type: 'done' + data?: { session_id?: string } + session_id?: string +} + +export interface ChatErrorEvent { + type: 'error' + data: string +} + +// The turn was queued behind an in-flight turn for this session (plan +// 2026-08-03 F2). No live assistant stream follows in this response; the +// queued turn runs server-side when the gate frees and the poller surfaces it. +export interface ChatQueuedEvent { + type: 'queued' + data: string // session id +} + +export type ChatEvent = + | ChatSessionEvent + | ChatToolUseEvent + | ChatToolResultEvent + | ChatTextDeltaEvent + | ChatTextEvent + | ChatDoneEvent + | ChatErrorEvent + | ChatQueuedEvent + +// ---- Tool call result (merged from tool_use + tool_result SSE pairs) ---- + +export interface ToolCallResult { + type: 'tool_use' | 'tool_result' + name: string + id?: string + args?: Record + result?: unknown + error?: string + // Streaming command output for an in-flight `run` call — attached live from + // the execution.output event stream (execstream.ts) while the call is still + // running, so the tool card can show output as it arrives instead of all at + // once when the tool_result lands. Not present on persisted/historical calls. + liveOutput?: string + // Plan step this call belongs to (current generation only). Attached by + // ChatThread from the activity log so the inline trace can group a turn's + // tool calls under their step. Undefined for orphan calls (no plan / older + // generation / plan-less Q&A). + stepSeq?: number +} + +// ---- Message content (persisted messages from /agent/sessions/:id) ---- + +export interface MessageContent { + text?: string + thinking?: string + tool_calls?: ToolCallResult[] +} + +// ---- Live event stream (SSE /api/v1/events/stream) ---- + +export interface PlanProposedData { + steps: Array<{ id: string; seq: number; title: string; detail?: string; target_slug?: string }> + appended?: boolean +} + +export interface PlanStepEventData { + step_id?: string + seq?: number + status?: string + execution_id?: string +} + +export interface QuestionRaisedData { + question_id: string + prompt?: string + why?: string + options?: string[] + entities?: string[] +} + +export interface QuestionAnsweredData { + question_id: string + answer?: string +} + +export interface EntityTouchedData { + slug?: string + tool?: string +} + +export interface HealthChangedData { + slug?: string + from?: string + to?: string +} + +// ---- Wails desktop bridge (injected into window) ---- + +export interface WailsCall { + ByName: (method: string, ...args: unknown[]) => unknown +} + +export interface WailsGlobal { + Call?: WailsCall +} diff --git a/web/src/lib/utils.test.ts b/web/src/lib/utils.test.ts new file mode 100644 index 0000000..3863ca3 --- /dev/null +++ b/web/src/lib/utils.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { relativeTime, truncateMiddle, debounce } from './utils' + +describe('relativeTime', () => { + it('returns "never" for null/undefined/empty', () => { + expect(relativeTime(null)).toBe('never') + expect(relativeTime(undefined)).toBe('never') + expect(relativeTime('')).toBe('never') + }) + + it('returns "just now" for future timestamps', () => { + const future = new Date(Date.now() + 10_000).toISOString() + expect(relativeTime(future)).toBe('just now') + }) + + it('formats seconds/minutes/hours/days', () => { + const now = Date.now() + expect(relativeTime(new Date(now - 5_000).toISOString())).toBe('5s ago') + expect(relativeTime(new Date(now - 120_000).toISOString())).toBe('2m ago') + expect(relativeTime(new Date(now - 3_600_000).toISOString())).toBe('1h ago') + expect(relativeTime(new Date(now - 86_400_000 * 2).toISOString())).toBe('2d ago') + }) +}) + +describe('truncateMiddle', () => { + it('returns the string unchanged when at or under maxLen', () => { + expect(truncateMiddle('short', 36)).toBe('short') + expect(truncateMiddle('exactly36chars_exactly36chars_xxxxx', 36)).toBe( + 'exactly36chars_exactly36chars_xxxxx' + ) + }) + + it('truncates in the middle, preserving both ends', () => { + const out = truncateMiddle('investigation:nomos/dragonfly-memlock-overrun', 20) + expect(out).toContain('…') + // keep = floor((20 - 1) / 2) = 9 chars from each end + expect(out.startsWith('investiga')).toBe(true) + expect(out.endsWith('-overrun')).toBe(true) + expect(out.length).toBeLessThanOrEqual(20) + }) +}) + +describe('debounce', () => { + it('collapses rapid calls into one trailing invocation', async () => { + let calls = 0 + const fn = debounce(() => calls++, 20) + fn() + fn() + fn() + expect(calls).toBe(0) + await new Promise((r) => setTimeout(r, 40)) + expect(calls).toBe(1) + }) +}) diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000..f4ee9a4 --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,47 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +// relativeTime renders a compact "Xs/Xm/Xh/Xd ago" label for freshness +// indicators (health checks, live event timestamps, etc). +export function relativeTime(iso: string | null | undefined): string { + if (!iso) return 'never' + const ms = Date.now() - new Date(iso).getTime() + if (ms < 0) return 'just now' + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + return `${d}d ago` +} + +// Truncates long slugs/names in the middle (keeping the "type:" prefix and +// the tail visible) rather than at the end — for slugs the distinguishing +// part is often at both ends, e.g. "investigation:nomos/dragonfly-memlock-…" +// vs "…rlimit-type-8-in-unprivileged-lxcs". +export function truncateMiddle(s: string, maxLen = 36): string { + if (s.length <= maxLen) return s + const keep = Math.floor((maxLen - 1) / 2) + return `${s.slice(0, keep)}…${s.slice(-keep)}` +} + +// debounce wraps fn so rapid calls (e.g. keystrokes in a filter input) +// collapse into one invocation after `wait`ms of silence. +export function debounce void>(fn: T, wait = 300): T { + let timer: ReturnType | undefined + return ((...args: Parameters) => { + clearTimeout(timer) + timer = setTimeout(() => fn(...args), wait) + }) as T +} + +export type WithoutChild = T extends { child?: unknown } ? Omit : T +export type WithoutChildren = T extends { children?: unknown } ? Omit : T +export type WithoutChildrenOrChild = WithoutChildren> +export type WithElementRef = T & { ref?: U | null } diff --git a/web/src/lib/version.ts b/web/src/lib/version.ts new file mode 100644 index 0000000..2773223 --- /dev/null +++ b/web/src/lib/version.ts @@ -0,0 +1 @@ +export const VERSION: string = __OIKOS_VERSION__ diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..1657dbf --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,42 @@ +import { mount } from 'svelte' +import App from './App.svelte' +import './app.css' +import { initConfig, setConfig, getConfig, isConfigured } from '$lib/config' + +// Dev convenience: `npm run dev` already proxies /api and /agent with +// OIKOS_API_TOKEN baked in server-side (vite.config.ts's authProxy), so the +// SPA's own token only matters for the one route that reads it from a query +// param (config.ts's sseUrl). Auto-fill it here so the "Connect to Oikos" +// prompt doesn't reappear every time localStorage is cleared — only when +// nothing's configured yet, so it never clobbers a deliberate manual +// connection (e.g. pointing dev at a remote server). +function devAutoConfig() { + if (import.meta.env.DEV && __OIKOS_DEV_TOKEN__ && !isConfigured()) { + setConfig({ apiUrl: '', token: __OIKOS_DEV_TOKEN__ }) + } +} + +function handleDesktopToken() { + const params = new URLSearchParams(location.search) + const token = params.get('token') + if (token && new URLSearchParams(location.search).has('desktop')) { + const apiUrl = params.get('apiUrl') || getConfig().apiUrl || '' + setConfig({ apiUrl, token, isDesktop: true }) + initConfig({ apiUrl, token, isDesktop: true }) + params.delete('token') + const q = params.toString() + history.replaceState(null, '', location.pathname + (q ? '?' + q : '')) + return true + } + return false +} + +function start() { + initConfig() + devAutoConfig() + handleDesktopToken() + + mount(App, { target: document.getElementById('app')! }) +} + +start() diff --git a/web/src/pages/AppStore.svelte b/web/src/pages/AppStore.svelte new file mode 100644 index 0000000..6eb609b --- /dev/null +++ b/web/src/pages/AppStore.svelte @@ -0,0 +1,98 @@ + + +
+
+

App Store

+

+ Installable apps. Local bundles for now — a remote catalog + sandboxing is Phase 4. +

+
+ +
+ {#if CATALOG.length === 0} +

No apps available yet.

+ {:else} +
    + {#each CATALOG as entry (entry.manifest.id)} + {@const m = entry.manifest} + {@const isOn = installedSet.has(m.id)} +
  • +
    + +
    +
    +
    +

    {m.title}

    + v{m.version} + {#if m.author}by {m.author}{/if} +
    +

    {m.description}

    +
    + + + {#if m.permissions.length}{m.permissions.join(', ')}{:else}no permissions + requested{/if} + +
    +
    +
    + {#if isOn} + + {:else} + + {/if} +
    +
  • + {/each} +
+ {/if} + +
+

+ + Installed apps appear on the desktop immediately. Uninstalling closes any open window for that + app. +

+
+
+
diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte new file mode 100644 index 0000000..756dbfa --- /dev/null +++ b/web/src/pages/Config.svelte @@ -0,0 +1,214 @@ + + +
+ + +
+
+ +
+ + +
+

Connect to Oikos

+

Configure your control room connection

+
+
+ + +
+ +
+ + +
+
+ + {#if showOidcContinue} + +
+

+ Logged in as {oidcUser} +

+
+ + {#if onCancel} + + {/if} +
+ +
+ {:else} + +
+ + + +
+ + or use + +
+ + +
{ + e.preventDefault() + connect() + }} + > +
+ + +
+ +
+
+ {/if} + + + {#if error} +

+ {error} +

+ {/if} + + + {#if !showOidcContinue && (onCancel || existing.token)} +
+ {#if onCancel} + + {/if} + {#if existing.token} + + {/if} +
+ {/if} +
+
+
diff --git a/web/src/pages/EntityGraph.svelte b/web/src/pages/EntityGraph.svelte new file mode 100644 index 0000000..ca670be --- /dev/null +++ b/web/src/pages/EntityGraph.svelte @@ -0,0 +1,711 @@ + + +
+ +
+
+ + {#if loading} +
+
+
+ Loading entity graph… +
+
+ {:else if loadError} +
+
+ + {loadError} +
+
+ {:else} + +
+ + + +
+ + +
+ + +
+ {/if} +
+ + + +
\ No newline at end of file diff --git a/web/src/pages/Knowledge.svelte b/web/src/pages/Knowledge.svelte new file mode 100644 index 0000000..2ec1199 --- /dev/null +++ b/web/src/pages/Knowledge.svelte @@ -0,0 +1,267 @@ + + +
+
+

Knowledge

+
+ {items.length} notes + +
+ + +
+
+
+ +
+ {#if itemsLoading} + + + +
+ + +
+ {#each Array(4) as _, gi (gi)} +
+ +
+ {#each Array(3) as _, ri (ri)} + + {/each} +
+
+ {/each} +
+
+
+ +
+ + +
+ {#each Array(4) as _, i (i)} +
+ + +
+ {/each} +
+ +
+ {#each Array(5) as _, i (i)} + + {/each} +
+
+
+
+ {:else if loadError} +
+

{loadError}

+ +
+ {:else if mode === 'wiki'} + + +
+ (newDialogOpen = true)} + /> +
+
+ +
+ (newDialogOpen = true)} + onChanged={loadItems} + bind:dirty={readerDirty} + /> +
+
+ + {#if selectedItem} + +
+ +
+
+ {/if} +
+ {:else} + + {/if} +
+
+ + + + + { + if (!o) pendingSlug = null + }} +> + + + Discard unsaved changes? + + You're editing a note. Switching now will discard what you haven't saved. + + + + + + + + diff --git a/web/src/pages/KnowledgeBase.svelte b/web/src/pages/KnowledgeBase.svelte new file mode 100644 index 0000000..4bb9613 --- /dev/null +++ b/web/src/pages/KnowledgeBase.svelte @@ -0,0 +1,277 @@ + + +
+ +
+ + + {#if view === 'table'} +
+ + +
+ {filteredEntities.length} of {allEntities.length} + {/if} + +
+ + +
+
+ + +
+ {#if view === 'graph'} + + {:else} + + {/if} +
+
diff --git a/web/src/pages/Learning.svelte b/web/src/pages/Learning.svelte new file mode 100644 index 0000000..ef454b4 --- /dev/null +++ b/web/src/pages/Learning.svelte @@ -0,0 +1,194 @@ + + +
+

Learning

+ + + + Execution outcomes — last 30 days + Every gated action, by day it ran, succeeded vs failed. + + + {#if trend.length === 0} + {#if !loading}

+ No executions in the last 30 days yet. +

{/if} + {:else} +
+ {/if} +
+
+ + + + Capability timeline + What Nomos has learned to do, ordered by when it first succeeded. + + +
+ {#each timeline as item (item.verb)} +
+
+ {item.verb} + + {item.first_success + ? `first succeeded ${fmtDate(item.first_success)}` + : 'no successes yet'} + +
+ {item.successes}/{item.total} +
+ {:else} + {#if !loading}

+ No executions yet. +

{/if} + {/each} +
+
+
+ + + + Patterns + Statistically validated behaviors, extracted from outcome feedback. + + + {#if patterns.length === 0} +

+ No patterns learned yet — patterns emerge once outcome feedback is recorded for repeated + actions. +

+ {:else} +
+ {#each patterns as p (p.id)} +
+
+ {p.pattern} + {p.applies_type} · {p.action} +
+ {(p.confidence * 100).toFixed(0)}% conf. +
+ {/each} +
+ {/if} +
+
+ + + + Promoted skills + Procedures promoted from validated patterns. + + + {#if skills.length === 0} +

No skills promoted yet.

+ {:else} +
+ {#each skills as s (s.id)} +
+
+ {s.name} + {s.status} +
+ {#if s.success_rate != null} + {(s.success_rate * 100).toFixed(0)}% success + {/if} +
+ {/each} +
+ {/if} +
+
+
diff --git a/web/src/pages/Ops.svelte b/web/src/pages/Ops.svelte new file mode 100644 index 0000000..e5276c3 --- /dev/null +++ b/web/src/pages/Ops.svelte @@ -0,0 +1,226 @@ + + +
+

Operations ledger

+ + + + + Approvals {#if pendingApprovals.length}{pendingApprovals.length}{/if} + + Activity + + + + + + {#if decidedApprovals.length} +

Recently decided

+
+ +
+ {/if} +
+ + + + +
+
diff --git a/web/src/pages/Overview.svelte b/web/src/pages/Overview.svelte new file mode 100644 index 0000000..84402cb --- /dev/null +++ b/web/src/pages/Overview.svelte @@ -0,0 +1,122 @@ + + +
+
+ {#each FILTERS as f} + + {/each} +
+ +
+ +
+ +
+
diff --git a/web/src/pages/Settings.svelte b/web/src/pages/Settings.svelte new file mode 100644 index 0000000..00d3cae --- /dev/null +++ b/web/src/pages/Settings.svelte @@ -0,0 +1,446 @@ + + +
+
+ + +
+ {#if section === 'connection'} +
+
+

Connection

+

+ Where the control room talks to your Oikos server. +

+
+ + {#if oidcUser} +

+ Signed in via Authentik as {oidcUser} +

+ {/if} + +
+ +
+ + +
+
+ + + +
+ + or use + +
+ +
{ + e.preventDefault() + save() + }} + > +
+ + +
+ +
+ + {#if error} +

+ {error} +

+ {/if} + + {#if existing.token || oidcConfigured} +
+ +
+ {/if} +
+ {:else if section === 'appearance'} +
+
+

Appearance

+

+ Pick the theme for the whole desktop. +

+
+ +
+ {#each Object.entries(THEME_LABELS) as [id, label] (id)} + + {/each} +
+ + + +
+

Desktop background

+

+ A subtle CSS pattern behind your icons, in the style of + magicpattern.design. +

+
+ +
+ {#each PATTERNS as p (p.id)} + {@const active = getBackground().pattern === p.id} + + {/each} +
+ + +
+ +
+ setPatternColor((e.currentTarget as HTMLInputElement).value)} + /> + {getBackground().color} +
+
+ +
+
+ onFillToggle(!!v)} + /> + +
+
+ + {getBackground().fillColor ?? 'Auto'} +
+
+ +
+
+ + {Math.round(getBackground().opacity * 100)}% +
+ +
+ +
+
+ + + {getBackground().fade === 0 ? 'Off' : `${Math.round(getBackground().fade * 100)}%`} + +
+ +

+ Fades the pattern out toward the edges, like a vignette. +

+
+ +
+
+ + {Math.round(getBackground().scale * 100)}% +
+ +
+ +
+
+ + {Math.round(getBackground().rotation)}° +
+ +
+
+ {/if} +
+
+ +
+ Oikos {VERSION} +
+
diff --git a/web/src/pages/Signals.svelte b/web/src/pages/Signals.svelte new file mode 100644 index 0000000..86ad50c --- /dev/null +++ b/web/src/pages/Signals.svelte @@ -0,0 +1,162 @@ + + +
+
+

Signals

+ + + {severityFilter === 'all' ? 'All severities' : severityFilter} + + + All severities + Critical + Warning + Info + + +
+ + + + + Open {#if open.length}{open.length}{/if} + + Muted + Resolved + + + + + + + + + + + +
diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts new file mode 100644 index 0000000..68d0c3b --- /dev/null +++ b/web/src/test-setup.ts @@ -0,0 +1,17 @@ +// jsdom doesn't implement matchMedia — anything that transitively imports +// theme.svelte.ts (which reads the OS color-scheme preference at module +// load) throws without this. Global vitest setup so every test file gets it +// for free instead of each one needing its own mock. +if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + }) as MediaQueryList +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..2b419d9 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,4 @@ +/// + +declare const __OIKOS_VERSION__: string +declare const __OIKOS_DEV_TOKEN__: string diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..2fc3563 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/web/vendor/LICENSE b/web/vendor/LICENSE new file mode 100644 index 0000000..361d30c --- /dev/null +++ b/web/vendor/LICENSE @@ -0,0 +1,224 @@ +"Commons Clause" License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined +below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under the +License will not include, and the License does not grant to you, the right to +Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of the rights +granted to you under the License to provide to third parties, for a fee or other +consideration (including without limitation fees for hosting or consulting/ +support services related to the Software), a product or service whose value +derives, entirely or substantially, from the functionality of the Software. Any +license notice or attribution required by the License must also include this +Commons Clause License Condition notice. + +Software: Orby +License: Apache License 2.0 +Licensor: Joan Sterjo + +------------------------------------------------------------------------------- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/web/vendor/NOTICE b/web/vendor/NOTICE new file mode 100644 index 0000000..bc03097 --- /dev/null +++ b/web/vendor/NOTICE @@ -0,0 +1,7 @@ +Orby +Copyright 2026 Joan Sterjo + +This product includes software developed by Joan Sterjo. + +The Apache License 2.0 grant is subject to the Commons Clause License +Condition v1.0. See LICENSE for the complete terms. diff --git a/web/vendor/README.md b/web/vendor/README.md new file mode 100644 index 0000000..04b680c --- /dev/null +++ b/web/vendor/README.md @@ -0,0 +1,943 @@ +

+ Orby — A procedural glyph engine by Joan Sterjo, shown as a deterministic pixel field +

+ +

Orby

+ +

+ A procedural glyph engine by Joan Sterjo.
+ Design one visual identity, invoke semantic states from product code, and keep + every transition responsive, reproducible, and alive. +

+ +

+ Open the live Studio + · + Download v5.0.0 + · + Quick-start guide + · + Runnable examples +

+ +

+ Version 5.0.0 + Canvas 2D and native ESM + Zero runtime dependencies + TypeScript declarations included + Source-available license: Apache 2.0 with Commons Clause +

+ +Orby turns product intent—ready, listening, thinking, using a tool, progressing, +completed, failed—into a coherent live glyph. Each frame combines an analytic +silhouette, a seeded field, a persistent pixel gate, and a spatial transition. +The result is motion with identity, not a generic loading ornament. + +Orby is distributed as `@joan/procedural-glyph-engine`. Its primary runtime +class is `JoanGlyphEngine`, its browser-native element is ``, and +portable Studio recipes use the `.joan.json` format. + +The runtime has no third-party dependencies. It ships as native ES modules with +TypeScript declarations, a browser-native web component, deterministic exports, +and a complete offline Studio. + +> [!IMPORTANT] +> Orby is source-available under the [Apache License 2.0 subject to the +> Commons Clause License Condition v1.0](./LICENSE). You may use, copy, modify, +> and redistribute it, including inside a larger value-added product. You may +> not sell Orby or a product or service whose value derives entirely or +> substantially from Orby's functionality. + +## Choose your path + +| I want to… | Start here | +| --- | --- | +| Explore states and tune a recipe | [Open the live Studio](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#playground) | +| Download everything for offline use | [Get the complete integration kit](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download) | +| Integrate the runtime into a product | [Follow the focused quick-start guide](./docs/QUICKSTART.md) | +| Try plain Canvas, web component, or timed sequences | [Run the included examples](./examples/README.md) | +| Understand the engine deeply | [Architecture](#architecture) · [state language](#the-25-semantic-sprites) · [API](#api-shape) | +| Operate it responsibly | [Accessibility](#accessibility-and-reduced-motion) · [performance](#performance-guidance) · [development](#development-test-and-build) | + +
+Complete contents + +- [Quick start](#quick-start) +- [Why Orby](#why-orby) +- [What Orby includes](#what-orby-includes) +- [Self-service download kit](#self-service-download-kit) +- [Architecture](#architecture) +- [The 25 semantic sprites](#the-25-semantic-sprites) +- [Runtime API](#runtime-api) +- [Web component](#web-component) +- [Custom glyphs](#custom-glyphs) +- [Signals, audio, and progress](#signals-audio-and-progress) +- [Export](#export) +- [Timed state sequences](#timed-and-pre-recorded-state-sequences) +- [StateDirector](#statedirector) +- [Studio preset library](#studio-preset-library) +- [Accessibility and reduced motion](#accessibility-and-reduced-motion) +- [Development, test, and build](#development-test-and-build) +- [Performance guidance](#performance-guidance) +- [License](#license) + +
+ +## Quick start + +Install the extracted integration kit from a consuming project: + +```sh +npm install ./joan-procedural-glyph-engine-5.0.0 +``` + +Mount one long-lived engine instance, then drive it with real product state: + +```html + + + +``` + +For direct, unbundled browser use, replace the package import with +`./src/joan-engine.js`. For a declarative integration, use the included +[`` web component](#web-component). For pre-recorded or timed flows, +use [`playStateSequence()`](#timed-and-pre-recorded-state-sequences). + +## Why Orby + +| Semantic by default | Deterministic by design | Portable by construction | +| --- | --- | --- | +| Twenty-five states cover the real lifecycle of AI work, from ambient readiness to completion and recovery. | A stable seed preserves visual identity across frames, products, previews, and exports. | Use Canvas 2D, native ESM, a web component, serialized configurations, or a single offline HTML Studio. | + +| 25 semantic states | 19 seeded fields | 12 transitions | 12 switch systems | 9 pixel geometries | 68×68 default grid | +| ---: | ---: | ---: | ---: | ---: | ---: | +| Presence → recovery | Quiet → chaotic | Morph → ignite | Dither → trace | Disc → composites | Adaptive quality | + +## What Orby includes + +- 25 immutable semantic sprite recipes covering presence, field-only ambient + motion, AI activity, status, transfer, and handoff states. +- 19 seeded scalar fields: `fbm`, `ridged`, `domain-warp`, `curl`, `flow`, + `worley`, `voronoi`, `plasma`, `interference`, `vortex`, `metaballs`, + `caustics`, `strata`, `radar`, `constellation`, `liquid`, `electric`, + `ripple`, and `kaleidoscope`. +- 12 pixel-switch strategies: `ordered-dither`, `temporal-blue-noise`, + `threshold-hysteresis`, `sdf-wavefront`, `contour-trace`, `curl-advect`, + `neighbor-propagation`, `radial-cascade`, `path-draw`, `axis-flip`, + `seeded-dissolve`, and `field-morph`. +- 12 transition strategies: `field-morph`, `seeded-dissolve`, + `radial-cascade`, `angular-sweep`, `scanline`, `contour-trace`, `path-draw`, + `axis-flip`, `cluster-dissolve`, `neighbor-ignite`, `glitch-bands`, and + `instant`. +- Nine Canvas 2D pixel shapes: `disc`, `square`, `diamond`, `capsule`, `line`, + `ring`, `cross`, `square-cross`, and `square-cross-ring`. The composite + shapes choose one primitive per cell from its sampled signal lightness. +- Analytic glyph masks with progress, audio-energy, pointer, press, signal, and + reduced-motion inputs. +- Seeded ordered dithering, dwell time, hysteresis, afterglow, and spring + response so pixels switch deliberately instead of flickering at a threshold. +- Runtime registration of sampler functions and bitmap glyphs, plus browser + image-file import. +- PNG, JSON-safe configuration, static SVG, and deterministic frame-sampled + animated SVG export without mutating the live engine. +- A semantic timing helper for escalating long-running reasoning states. +- Serializable named state sequences plus one-off timed playback with + cancellation, pause, resume, and stop controls. + +## Self-service download kit + +The website's **Download** section publishes a versioned ZIP assembled from the +same runtime source used by the live Studio. The complete integration kit +contains: + +- the complete native ESM runtime in `src/`; +- all TypeScript declarations in `types/`; +- this full API and integration reference; +- a focused quick start in `docs/QUICKSTART.md`; +- runnable canvas, web-component, and sequence examples; +- the self-contained `joan-engine-v5.standalone.html` Studio; +- package metadata and a runtime manifest; +- the complete `LICENSE` and `NOTICE`; and +- `SHA256SUMS.txt` covering every packaged payload file. + +Beside the ZIP, the website publishes its `.sha256` file and a machine-readable +release manifest. After extracting the kit, install that local folder from a +consuming project—the package is not currently registry-published: + +```sh +npm install ./joan-procedural-glyph-engine-5.0.0 +``` + +Alternatively, serve the extracted folder and import `./src/joan-engine.js` +directly, or open the standalone Studio without a build step. The archive grants +the same source-available permissions—and carries the same no-sale condition—as +the repository. See [`LICENSE`](./LICENSE) for the complete terms. + +## Architecture + +The renderer is intentionally layered. Each layer can be used independently or +composed by `JoanGlyphEngine`. + +| Module | Responsibility | +| --- | --- | +| `src/fields.js` | Seed hashing, gradient/value/cellular noise, Bayer dithering, and the canonical scalar-field registry. Every named field samples to `0..1`. | +| `src/glyphs.js` | Resolution-aware analytic glyph coverage functions. Coordinates are normalized to `-1..1`; coverage is `0..1`. | +| `src/sprites.js` | Deep-frozen semantic recipes: glyph, field stack, palette, switching, timing, interaction, labels, and reduced-motion representation. | +| `src/joan-engine.js` | Canvas lifecycle, state transitions, interaction impulses, pixel gating, spring dynamics, drawing, exports, and events. | +| `src/web-component.js` | The `` custom element and its attribute-to-engine adapter. | +| `src/state-director.js` | Optional, explicit orchestration for thinking, deep-thinking, still-working, completion, failure, and reset. | +| `src/state-sequence.js` | Reusable, serializable timelines for timed state choreography, playback control, and cancellation. | +| `src/studio.js` | Interactive demo/studio wiring. It is not required by the runtime. | + +The frame pipeline keeps meaning, identity, motion, and output as explicit +layers: + +```mermaid +flowchart LR + A[Semantic state] --> B[Analytic glyph or field orb] + A --> C[Seeded procedural field stack] + D[Signals and interaction] --> B + D --> C + B --> F[Spatial transition] + C --> F + F --> E[Dither · hysteresis · dwell pixel gate] + E --> G[Spring and afterglow] + G --> H[Canvas · PNG · SVG] +``` + +Seeds affect the field permutation and per-cell decisions. The same seed, +sprite, coordinates, time, and options produce the same field samples. Animation +time and live interaction still intentionally change a rendered frame. + +## The 25 semantic sprites + +These IDs are the stable built-in catalog. Short aliases such as `thinking`, +`success`, `error`, `upload`, and `handoff` are accepted, but product code should +prefer the canonical IDs. + +
+View all 25 canonical state IDs + + +| Canonical ID | Default label | Intended meaning | +| --- | --- | --- | +| `ai.idle` | Ready | Ready and available | +| `ai.ambient-idle` | Ambient ready | Calm presence expressed only through a field | +| `ai.ambient-thinking` | Ambient thinking | Reasoning expressed only through a field | +| `ai.ambient-thinking-symmetric` | Ambient thinking — symmetric | Clean, symmetrical reasoning expressed only through a field | +| `ai.ambient-speaking` | Ambient speaking | Voice output expressed only through a field | +| `ai.listening` | Listening | Capturing voice or input | +| `ai.thinking` | Thinking | Reasoning | +| `ai.thinking-deep` | Reasoning deeply | Deliberate extended reasoning | +| `ai.still-working` | Still working | Work is taking longer than expected | +| `ai.loading` | Loading | Indeterminate startup or wait | +| `ai.progress` | In progress | Determinate completion progress | +| `ai.generating` | Generating | Producing content | +| `ai.searching` | Searching | Searching or retrieving information | +| `ai.tool-use` | Using a tool | Executing a tool or action | +| `ai.speaking` | Speaking | Producing voice output | +| `ai.awaiting-input` | Your input is needed | User action is required | +| `status.success` | Completed | Completed successfully | +| `status.warning` | Warning | Attention is needed for a nonfatal issue | +| `status.error` | Error | Operation failed | +| `status.paused` | Paused | Work is suspended and can resume | +| `status.cancelled` | Cancelled | Operation was stopped | +| `status.offline` | Offline | Disconnected or unavailable | +| `transfer.active` | Transferring | Uploading, downloading, or synchronizing data | +| `workflow.handoff` | Handing off | Passing work to another agent or person | +| `status.celebration` | Milestone completed | A milestone or high-value success | + +
+ +Use `listSprites()` to obtain the frozen ordered catalog and `getSprite(id)` to +resolve either a canonical ID or alias. + +## Runtime API + +### Package entry points + +Every JavaScript entry point is native ESM and carries TypeScript declarations. + +| Import | Provides | +| --- | --- | +| `@joan/procedural-glyph-engine` | `JoanGlyphEngine`, `createGlyph()`, core catalogs, and rendering utilities | +| `@joan/procedural-glyph-engine/config` | Strict option inspection, validation, and frozen recipe helpers | +| `@joan/procedural-glyph-engine/fields` | Seeded field samplers, noise helpers, and field registration | +| `@joan/procedural-glyph-engine/glyphs` | Analytic glyph masks and glyph registration helpers | +| `@joan/procedural-glyph-engine/sprites` | Immutable semantic sprite catalog and aliases | +| `@joan/procedural-glyph-engine/state-director` | Escalation timing for long-running task presentation | +| `@joan/procedural-glyph-engine/state-sequence` | Serializable timelines and controlled timed playback | +| `@joan/procedural-glyph-engine/web-component` | `` element, definition helper, and automatic browser registration | +| `@joan/procedural-glyph-engine/web-component/register` | Explicit side-effect registration for `` | +| `@joan/procedural-glyph-engine/styles.css` | Default web-component presentation styles | + +### Engine construction + +Create one long-lived engine instance per surface. A more fully authored setup +can override the active recipe while preserving the same semantic API: + +```js +import { createGlyph } from "@joan/procedural-glyph-engine"; + +const glyph = createGlyph("#ai-glyph", { + sprite: "ai.idle", + seed: "conversation-42", + gridSize: 68, + pixelShape: "disc", + pixelSwitch: "threshold-hysteresis", + transition: "field-morph", + orbBoundary: "gestalt", + orbBackgroundColor: "#14212b", + orbBackgroundMode: "pixelated", + speed: 1, + density: 1, +}); + +await glyph.transitionTo("ai.thinking", { + transition: "neighbor-ignite", + duration: 0.5, + preservePhase: true, +}); + +// Release observers, listeners, and the animation frame when unmounting. +glyph.destroy(); +``` + +`setSprite()` is synchronous and chainable. `transitionTo()` resolves when the +visual transition completes and accepts an `AbortSignal`; use it when product +flow must wait for presentation. For a direct, unbundled demo import, replace +the package import with `./src/joan-engine.js`. + +`createGlyph(canvasOrSelector, options)` is the preferred factory. +`createProceduralGlyph({ canvas, ...options })` and +`mountProceduralGlyph(target, options)` provide equivalent construction forms. + +The constructor form is equivalent: + +```js +import JoanGlyphEngine from "@joan/procedural-glyph-engine"; + +const glyph = new JoanGlyphEngine(canvas, { + sprite: "ai.loading", + autoplay: true, +}); +``` + +### Lifecycle and configuration + +Useful lifecycle and configuration methods include `play()`, `pause()`, +`toggle()`, `activate()`, `resume()`, `renderOnce()`, `setSeed()`, +`setResolution()`, `setField()`, `setPixelShape()`, `setPixelSwitch()`, +`setNonErrorPalette()`, `setOptions()`, `transitionTo()`, +`whenTransitionComplete()`, `configure()`, `inspect()`, `exportConfig()`, +`toDataURL()`, `toBlob()`, +`toSVG()`, `toAnimatedSVG()`, `downloadPNG()`, `downloadSVG()`, +`downloadAnimatedSVG()`, and `destroy()`. + +Built-in sprites use layered field stacks. `setField("radar")` deliberately +replaces that stack with one field; call `useRecipeFields()` to restore the +sprite's authored composition. `exportConfig()` records this distinction as +`fieldMode: "recipe" | "override"`, so configurations round-trip faithfully. +Recipe-owned pixel geometry, switching, and transitions serialize as `null`; +explicit global overrides serialize as their string or structured object. This +keeps reconstructed engines on each future state's authored motion recipe. + +### Orb boundary and background + +Set `orbBoundary: "gestalt"` to replace the continuous circular rim with an +implied edge built from separated pixel clusters. The default is +`orbBoundary: "defined"`, which preserves the authored hard outline. The +Gestalt treatment is available for `ai.idle`, `ai.ambient-idle`, +`ai.ambient-thinking`, and `ai.ambient-speaking`; other sprites retain their +authored silhouette. The option remains configured when moving between states, +so one engine can carry the same boundary preference through a product flow. +Custom circular recipes can opt in with +`composition: { gestaltBoundary: true, gestaltOpenness: 0.5 }`. + +```js +glyph.setOptions({ orbBoundary: "gestalt" }); +glyph.setOptions({ orbBoundary: "defined" }); // restore the continuous rim +``` + +`orbBackgroundColor` supplies the color for an independent fill behind the +pixels of the same four supported presence orbs. Choose its treatment with +`orbBackgroundMode`: `"none"` disables the layer, `"solid"` draws a smooth +circle, and `"pixelated"` builds the circle from grid-aligned row runs. The +pixelated mode follows the selected engine resolution, so its edge belongs to +the same grid as the foreground glyph. The fill carries through other states +without painting there, remains visible when the canvas background is disabled, +and is included in static and animated SVG exports. + +An initial `orbBackgroundColor` without an explicit mode selects `"solid"`. +Once `"pixelated"` is selected, later color-only patches +preserve that treatment so changing the swatch does not reset the shape. +Clearing the color with `null` or `"transparent"` disables the layer. Set the +mode explicitly when you want to retain a color while temporarily hiding it. + +```js +glyph.setOptions({ + orbBackgroundColor: "#14212b", + orbBackgroundMode: "pixelated", +}); +glyph.setOptions({ orbBackgroundMode: "none" }); // retain the chosen color +glyph.setOptions({ orbBackgroundMode: "solid" }); +glyph.setOptions({ orbBackgroundColor: null }); // disable and clear the color +``` + +Named options fail fast with a nearby-name suggestion instead of silently +falling back. Use the config subpath to validate before constructing an engine, +or to define and freeze a custom recipe: + +```js +import { getSprite } from "@joan/procedural-glyph-engine"; +import { + defineRecipe, + inspectEngineOptions, + validateEngineOptions, +} from "@joan/procedural-glyph-engine/config"; + +const options = validateEngineOptions({ + sprite: "thinking", // canonicalized to ai.thinking + field: "domain_warp", // canonicalized to domain-warp + gridSize: 68, + quality: "auto", +}); + +const inspection = inspectEngineOptions(untrustedOptions); +if (!inspection.ok) console.table(inspection.issues); + +const branded = defineRecipe({ + ...getSprite("ai.generating"), + id: "product.generating", + glyph: "product.mark", +}); +``` + +`validateEngineOptions()` is strict and non-mutating. `inspectEngineOptions()` +returns `{ ok, value, issues }`, safely +coerces ordinary HTML-style values, and omits invalid named options. + +### API shape + +| Operation | Return | Use it for | +| --- | --- | --- | +| `createGlyph(target, options)` | engine | The preferred canvas-or-selector invocation | +| `setSprite(sprite, options)` | engine | Immediate, chainable state commands | +| `transitionTo(sprite, options)` | `Promise` | Waiting for the visual handoff or cancelling it with `signal` | +| `configure(patch)` | engine | Strict, atomic runtime configuration with one consolidated event/render | +| `setOptions(patch)` | engine | Runtime configuration with safe coercion | +| `signal(type, payload)` | engine | Short-lived product events and semantic inputs | +| `on(type, listener)` | unsubscribe function | Typed event subscription with one-call cleanup | +| `inspect()` | JSON-safe snapshot | State, transition, signals, palette, quality, and performance debugging | +| `renderOnce(time)` | stats | Deterministic paused previews and test fixtures | +| `exportConfig()` | JSON-safe object | Reconstructing the authored runtime configuration | +| `destroy()` | `undefined` | Releasing frames, observers, and listeners | + +The package declarations expose literal unions for built-in sprites, fields, +pixel shapes, switches, transitions, quality settings, event payloads, recipes, +exports, the custom element, `StateDirector`, and `StateSequencePlayer`. + +### Color control + +Use `nonErrorPalette` to override any combination of the `background`, `off`, +`ink`, `accent`, and `glow` channels for every state except `status.error`. +Unspecified channels continue to come from each state’s authored palette. +Use `variants` when shared light or dark surfaces need to preserve the authored +semantic color family. Variant keys match palette names such as `info`, +`success`, `warning`, and `celebration`; explicit top-level channels still win. + +```js +const glyph = new JoanGlyphEngine(canvas, { + sprite: "ai.thinking", + nonErrorPalette: { + background: "#e5e6e2", + off: "#c7cbc8", + ink: "#17191e", + variants: { + info: { accent: "#4788aa", glow: "#6aa6c4" }, + success: { accent: "#397c57", glow: "#62a27b" }, + warning: { accent: "#9a6817", glow: "#bd8b3c" }, + }, + }, +}); + +glyph.setNonErrorPalette({ ink: "#ffffff", accent: "#53d6c7" }); +glyph.setNonErrorPalette({ glow: "transparent" }); // disable the halo +glyph.setNonErrorPalette(null); // restore authored state colors +``` + +The error state keeps its danger palette so a product-level color choice cannot +erase its failure semantics. The `palette` option provides an explicit global +override—including for errors—when that behavior is needed. +Hex and `rgb()` color values provide consistent Canvas and SVG output. + +In the studio, turn **Glow** off to select no glow color. The previous chosen +color is preserved and returns when Glow is enabled again. + +## Web component + +Importing the web-component subpath registers `` in a browser and +is safe to evaluate during SSR: + +```js +import "@joan/procedural-glyph-engine/web-component"; +``` + +An explicit side-effect entry is also available for application bootstrap: + +```js +import "@joan/procedural-glyph-engine/web-component/register"; +``` + +```html + +``` + +Size the host with CSS; its default size is `68px × 68px`. + +```css +joan-glyph { + inline-size: 3rem; + block-size: 3rem; +} +``` + +The five `non-error-*` color attributes are optional and may be added, changed, +or removed at runtime. The boolean `paused` attribute disables autoplay. +`noninteractive` disables pointer interaction and `transparent` disables the +painted background; both react when added or removed. Removing `field`, +`pixel-shape`, or `pixel-switch` restores the recipe-authored behavior. The +optional `orb-boundary` attribute accepts `gestalt`; removing it (or using an +unsupported value) restores the default `defined` boundary. The optional +`orb-background-color` attribute adds the orb-only fill; removing it clears the +fill without changing the canvas background. Use `orb-background-mode="none"`, +`"solid"`, or `"pixelated"` to choose the treatment. Supplying only +`orb-background-color` selects `solid`; removing the mode attribute returns to +that color-driven behavior. The +element forwards the engine's state, signal, palette, playback, configuration, +and inspection methods and exposes the underlying instance as `element.engine`. + +## Custom glyphs + +A sampler receives normalized `x`, `y`, animation time, and the live engine +context. Return coverage from `0` (off) to `1` (fully covered). Keep the hot +sampler pure and allocation-free. + +```js +import { + createProceduralGlyph, + getSprite, +} from "@joan/procedural-glyph-engine"; + +const glyph = createProceduralGlyph({ canvas, autoplay: true }); + +glyph.registerGlyph("product.spark", (x, y, time, context) => { + const radius = Math.hypot(x, y); + const spokes = Math.cos(Math.atan2(y, x) * 8 + time * 0.6); + const pulse = 0.04 * Math.sin(time * 1.4 + context.energy * Math.PI); + return radius < 0.42 + spokes * 0.08 + pulse ? 1 : 0; +}); + +const base = getSprite("ai.generating"); +glyph.setSprite({ + ...base, + id: "product.spark", + label: "Generating with Product", + glyph: "product.spark", + semantic: { + ...base.semantic, + category: "custom", + meaning: "generating branded output", + }, + labels: { + ...base.labels, + default: "Generating with Product", + aria: "Generating branded output", + }, +}); +``` + +A two-dimensional numeric array is accepted as a bitmap and its dimensions are +inferred: + +```js +glyph.registerGlyph("product.pixel-heart", [ + [0, 1, 0, 1, 0], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [0, 1, 1, 1, 0], + [0, 0, 1, 0, 0], +]); +``` + +In the browser, `loadGlyphFile(file, options)` rasterizes an image file and +activates it as a custom glyph: + +```js +await glyph.loadGlyphFile(fileInput.files[0], { + id: "product.uploaded-mark", + label: "Product mark", + baseSprite: "ai.generating", + resolution: 48, +}); +``` + +## Signals, audio, and progress + +Signals add short-lived spatial energy without changing semantic state. Signal +names are intentionally open-ended, so the host can mirror its own event model. + +```js +glyph.signal("token", { energy: 0.55 }); +glyph.signal("search.hit", { x: 0.35, y: -0.2, energy: 0.9, life: 0.8 }); +glyph.signal("tool.call", { energy: 1 }); +glyph.signal("audio.level", { value: microphoneLevel }); +glyph.signal("transfer.direction", { direction: "up" }); +glyph.signal("handoff.accepted", { accepted: true }); +glyph.signal("network.retry", { energy: 0.8 }); +glyph.signal("resume", { value: 1 }); // restores the state held before status.paused +``` + +`audio.level` updates the smoothed listening/speaking input. `progress` is +special-cased as determinate state: + +```js +glyph.setSprite("ai.progress"); +glyph.setProgress(0.42); // clamped to 0..1 + +// Equivalent low-level form: +glyph.signal("progress", { value: 0.42 }); +``` + +`transfer.active` also consumes progress. Register a direction-specific custom +glyph if the product must distinguish upload from download visually. + +The engine dispatches `spritechange`, `transitionqueued`, `transitioncomplete`, +`timelinecomplete`, `resume`, `configchange`, `signal`, `activate`, `play`, +`pause`, `qualitychange`, `stats`, and `destroy` events: + +```js +glyph.addEventListener("spritechange", ({ detail }) => { + console.log(`${detail.from} → ${detail.to}`); +}); +``` + +## Export + +`downloadPNG()` captures the current canvas. `toSVG()` / `downloadSVG()` create +a vector snapshot using the selected pixel geometry. `toAnimatedSVG()` / +`downloadAnimatedSVG()` simulate an isolated deterministic clone, sample its +pixel gates, and encode shape, opacity, and scale frames without changing the +live engine. + +```js +await glyph.downloadPNG("thinking.png"); +await glyph.downloadSVG("thinking.svg"); +await glyph.downloadAnimatedSVG("thinking.animated.svg", { + duration: 2.4, + fps: 12, + maxGridSize: 48, +}); +``` + +Animated export is intentionally capped and yields between frame batches to +keep the studio responsive. Raise `fps`, duration, or `maxGridSize` only after +checking file size and export time. + +## Timed and pre-recorded state sequences + +`StateSequencePlayer` turns product-owned state choreography into a small, +reusable API. Definitions are frozen and JSON-friendly, so a host can keep them +beside an agent workflow, load them from its own configuration, or construct a +one-off sequence at the point of use. + +```js +import { + StateSequencePlayer, + defineStateSequence, +} from "@joan/procedural-glyph-engine/state-sequence"; + +const idleThenDone = defineStateSequence("idle-then-done", [ + { sprite: "ai.idle", holdMs: 5_000 }, + { sprite: "status.success", transition: "path-draw" }, +]); + +const states = new StateSequencePlayer(glyph, { + sequences: [idleThenDone], + transition: "field-morph", +}); + +const controller = new AbortController(); +const result = await states.play("idle-then-done", { + signal: controller.signal, +}); +``` + +The first step is entered immediately by default. Each later step waits for its +visual transition to complete, then holds for `holdMs` before advancing. +`durationMs` is accepted as an alias for `holdMs`; transition `duration` values +continue to use the engine's seconds-based API. + +For an inline command, `playStateSequence()` starts immediately and returns the +playback controller: + +```js +import { playStateSequence } from + "@joan/procedural-glyph-engine/state-sequence"; + +const playback = playStateSequence(glyph, [ + { sprite: "ai.ambient-idle", holdMs: 1_500 }, + { sprite: "ai.progress", transition: "contour-trace" }, +]); + +playback.pause(); // freezes the current hold clock +playback.resume(); +await playback.finished; +``` + +Call `stop()` for an intentional early finish, or pass an `AbortSignal` when +the surrounding task owns cancellation. Aborts reject with `AbortError`; +`stop()` resolves with a `stopped` result. Starting another playback safely +stops the active run and starts the new one. Pausing freezes the hold clock and +step progression; +an already-running visual transition continues to settle. `sequencestart`, +`stepstart`, `statechange`, `stepenter`, +`stepcomplete`, `sequencepause`, `sequenceresume`, `sequencestop`, +`sequencecancel`, `sequencecomplete`, and `sequenceerror` events expose the +full lifecycle. Completed, stopped, cancelled, and destroyed players clear +their timeout and abort listeners. + +## StateDirector + +`StateDirector` is an optional presentation timer. The host still owns the real +task state; the director never guesses whether work started, succeeded, or +failed. + +```js +import { StateDirector } from + "@joan/procedural-glyph-engine/state-director"; + +const director = new StateDirector(glyph, { + deepThinkingAfterMs: 7_000, + stillWorkingAfterMs: 18_000, + transition: "field-morph", +}); + +director.beginThinking(); + +try { + await performWork(); + director.complete(); // status.success + a completion impulse +} catch (error) { + director.fail(); // status.error +} + +// Return to a clean phase for the next operation. +director.reset(); + +// Clear pending escalation timers when the owner unmounts. +director.destroy(); +glyph.destroy(); +``` + +`beginThinking()` moves from `ai.thinking` to `ai.thinking-deep`, then to +`ai.still-working` at the configured thresholds. `set()`, `complete()`, +`fail()`, or `reset()` cancels pending timers. + +For the common one-task lifecycle, `run()` removes the surrounding state +boilerplate and rethrows failures after presenting them: + +```js +const controller = new AbortController(); + +const result = await director.run( + ({ signal }) => performWork({ signal }), + { + signal: controller.signal, + deepThinkingAfterMs: 7_000, + stillWorkingAfterMs: 18_000, + successSprite: "status.success", + errorSprite: "status.error", + cancelledSprite: "status.cancelled", + }, +); +``` + +Starting another director operation prevents earlier asynchronous work from +overwriting its state. Aborting moves to `status.cancelled` and rejects with an +`AbortError`. Every manual, escalated, successful, failed, cancelled, and reset +state dispatches `statechange` with `{ state, from, to, reason }`. + +## Studio preset library + +`Save preset` and `Save current` add the tuned recipe to the visible **Saved +presets** tile library above the State Atlas. The Studio keeps up to 24 entries +in this browser's local storage under `joan.studio.recent-presets.v1`; they do +not sync to another browser or device. A tile restores its recipe, Delete has +an immediate Undo action, and **Prepare recipe** creates a portable `.joan.json` +file when a preset must move beyond browser-local Studio state. + +## Accessibility and reduced motion + +- The engine assigns the canvas `role="img"` and updates its accessible label + when interaction is disabled. Interactive canvases use button semantics, + support Enter and Space activation, and expose a visible focus treatment. + Pass an `ariaLive` element when state changes should be announced. +- Keep a visible text status beside the glyph. Do not communicate success, + warning, failure, progress, or waiting through motion or color alone. +- Avoid duplicate announcements: use `ariaLive` only if the surrounding product + does not already announce the same state. +- `reducedMotion: "system"` is the default. It follows + `prefers-reduced-motion`, pauses continuous autoplay, renders representative + glyph phases, and uses 100–120 ms transitions for the built-in recipes; + explicit reduced-motion durations remain capped at 200 ms. +- Set `reducedMotion: true` to force the static behavior or `false` only when the + product has a deliberate, user-controlled motion policy. +- Semantic recipes include a reduced-motion representation and use palettes + designed to remain understandable in monochrome, but application-level + contrast and surrounding copy still need product accessibility review. +- Call `setProgress()` even in reduced motion. Data changes remain meaningful + when spatial animation is removed. + +## Development, test, and build + +Requirements: Node.js 18 or newer; Node.js 20 is the tested handoff target. + +> [!NOTE] +> The commands in this section require a repository clone. The downloadable +> integration kit intentionally includes runtime sources, types, documentation, +> examples, and the offline Studio—not the project build scripts or test suite. + +```sh +# Run the local studio at http://127.0.0.1:4173 +npm run dev + +# Run dependency-free node:test contract tests +npm test + +# Recreate dist/, the standalone HTML build, and the website download kit +npm run build + +# Run tests, build, then verify the complete download artifact +npm run check + +# Validate, build, and create the installable .tgz package +npm pack +``` + +`npm pack` is suitable for local integration testing. The resulting package +includes `LICENSE` and `NOTICE` and carries the same source-available terms as +the repository. + +Set `JOAN_ENGINE_PORT` to use a different development port: + +```sh +JOAN_ENGINE_PORT=4400 npm run dev +``` + +The studio preview defaults to **Fit**. Switch it to **1:1** to center the glyph +at its actual grid footprint, with one engine cell mapped to one CSS pixel. This +is a studio-only inspection view and does not alter exported recipe configuration. + +The build copies the native module sources and studio assets into `dist/`, +creates the Sites-compatible `dist/client` and `dist/server/index.js` outputs, +generates the versioned ZIP, checksums, and release manifest in `downloads/`, +and creates `joan-engine-v5.standalone.html`. Commit source files rather than +editing generated output. + +## Performance guidance + +Rendering cost grows approximately with `gridSize²`. The runtime defaults to +68×68 and uses adaptive quality to hold its frame budget. Override that with +24–36 for compact product icons or dense multi-glyph surfaces. + +- Reuse an engine and call `setSprite()`; do not construct an engine for every + state change. +- For thumbnail grids, set `autoplay: false`, `autoResize: false`, + `interactive: false`, `quality: "low"`, `dprMax: 1`, and call `renderOnce()` + only when a preview needs updating. +- Use `fps: 24` or `30` for ambient UI. Reserve 60 fps for close, interactive + motion. +- Set `offPixels: false` to remove the background-dot draw pass. Disable the + painted background with `background: false` when the product surface already + supplies one. +- `quality: "auto"` adapts between `high`, `balanced`, and `low` from measured + frame cost. Read the effective tier from `stats.quality` or subscribe to + `qualitychange`. Balanced and low tiers time-slice field sampling across the + grid, and low also skips the glow path. Use a fixed high tier or + `renderOnce()` for full-grid deterministic visual fixtures. +- Cap device-pixel work with `dprMax`; a value of `1` is often enough for + pixel-art thumbnails and dense dashboards. +- Disable pointer work with `interactive: false` for decorative or + noninteractive instances. +- Keep custom sampler functions allocation-free and avoid DOM reads, object + creation, and network/state access inside them. +- Let the built-in visibility observation cancel animation-frame scheduling for + hidden and off-screen canvases; it resumes automatically when visible. Always + call `destroy()` when removing an instance. +- Watch the `stats` event (`fps`, `frameMs`, `sampledPixels`, `activePixels`, and `resolution`) in + realistic multi-glyph screens, not just an isolated demo. + +For deterministic visual regression fixtures, use `autoplay: false`, disable +interaction and auto-resize, set a fixed canvas size and seed, then call +`renderOnce(fixedTime)`. + +## License + +Orby is source-available under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) +subject to the [Commons Clause License Condition v1.0](https://commonsclause.com/). + +- You may use, copy, modify, and redistribute Orby, and include it in a larger + value-added product, subject to the full terms. +- You may not sell Orby or offer a product or service whose value derives + entirely or substantially from Orby's functionality. +- Keep the required license and attribution notices when redistributing it. + +Because it restricts selling, this is a **source-available** license rather than +an OSI-approved open-source license. Read [`LICENSE`](./LICENSE) for the complete +terms and [`NOTICE`](./NOTICE) for attribution. + +--- + +

+ Live Studio + · + Download kit + · + Back to top +

diff --git a/web/vendor/SHA256SUMS.txt b/web/vendor/SHA256SUMS.txt new file mode 100644 index 0000000..a2aa1a0 --- /dev/null +++ b/web/vendor/SHA256SUMS.txt @@ -0,0 +1,36 @@ +6dea5372df18fef1b3056ad6423552c7785a8a17e2f9fec705463f6028439b28 docs/assets/readme-hero.svg +99b15d457eeba4e7414bc210d7040019a54f9fa838291bde1d3875c1bbca78e9 docs/QUICKSTART.md +01b1bc8b37a39be3d8ae3be7da71668f0bcdfa0bcb07f847f91a59950ecebbc2 examples/canvas.html +cc08f683e352db9566b5839bc918302b0e681937cdc035aadcfc7bcae588c291 examples/example.css +0c46216df56ce372cafd26033a925f0462b47f510a014f1bc66a8b706b75d410 examples/README.md +c0b22bf741c75cc025ab7717a6db44dbc5fd93fb28396767d369c2003a9d83af examples/state-sequence.html +6802fde51637fe054cf870eb2c5f9a49907c474bc5ff347ea71cee7b3d636541 examples/web-component.html +cd26e4164fa1fac69c93c45723a4412f68bd667be2c94337d99877a374a71418 joan-engine-v5.standalone.html +31763c5e3583d9a5ad610f17456ad1e68f4fb2b73b7f53a735e418c3e1a7e81e LICENSE +7a9441bdc68f23ada12bc4782fc3f7962d795f3738735638b754c195fdd6c21d manifest.json +55cf377b8808993288f9aadfa309965eb0aaef8df3896fb487cc94c4f65c03b4 NOTICE +77fc5e0a77ee82154d3a328827b4cc3e530729bdda8bc18db656f1aa1d916909 package.json +a85a9d9b7f723672b630882d54a3c7e82675afb6d14ebcedf63f0e66e16f4bcd README.md +324f45dfca15074ab65508e433cc20aa2124a196dad09d98d0a1ed7c27e7c503 src/config.js +2bb9921205babd2204bdc03abc8a1bd2c916fa4ce4c203d8822e18289dd6f15d src/control-help.js +547603442b1db4739f047833a3da8ea6ed518c49753260e2d488417004340fae src/fields.js +a36015cbe69f7ce3b5c218189a4250a6baef7afe814492ed051643312bd34bf5 src/glyphs.js +a759eaad654030e5b143f02641a2a4c41f997641e0add34ba03ee217861d2dc0 src/joan-engine.js +1237112f05e55f46341e2b10e12f4b372d39cd7c34e05e62af8bb7ceb894250b src/preview-layout.js +75c0be5f862135dac7b598974f99fb91e1b3a925c28d69815f374d4ff17ef86b src/preview-thumbnails.js +c69b77f9c9592a0f9e417ee7be17f430139713945120d01478d43b7de587390d src/sprites.js +175a7172707611fe456db159182350d0967665f673c231f443e69660ae09038c src/state-director.js +ea45c075e503ee0dd4545556f61bbceff309dea722ceda96d5eb7baafa4f4e67 src/state-sequence.js +55f4d43cf50928aed47d9233653ab971593995923005154f926814acecb43129 src/studio.js +4372628fbca51ab2a19e4fbb0d2fd21bb0ef852fe17a61876188f1cacdab6aea src/styles.css +7b61ac0803413c4c766db4f03c049dc24ee47688086abb85e7ddfdc9db0250b7 src/web-component-register.js +4952d2ccdfdf98cc778840b464521983ac6e8ab215f90b1d865680258f808ac6 src/web-component.js +f1d8fe2141b766da1b4bba4b88306a436f0f1971fce8caf26f18a562e0e65abb types/config.d.ts +5d0c433ecf5356ed46a3edd318612ab76e818df1b4497a6c195b4c0c1f4c7cb5 types/fields.d.ts +59aa75e1149319de9b191c44feddeeef8e1a12ce4e662b057c2ecc31a7ca6e3a types/glyphs.d.ts +a62e4a17927b318a05580863141610b0767a00ee87031e17090e3597019572a3 types/index.d.ts +99309aab1f114260456db816072039a7f1d65a8ec5bf3fb5d3d0ec4c4fcd5536 types/sprites.d.ts +22a56518acd9626699d5bb39bb63a29aa285ef825b0b08bc4ec6bb6111cf4fb2 types/state-director.d.ts +3a6bc92f0e3e774d8bbabac49b6b35209ff6995cde04d5f9e37b8cc7c4b4f5db types/state-sequence.d.ts +59f77bee8bfe215a88d248f8ca06362905362154026f4868bbab58e733271a25 types/web-component-register.d.ts +127ee9b7678fb4090207a4d8784643ab52f5ec5db2567bf61dd0fefee40c8b50 types/web-component.d.ts diff --git a/web/vendor/docs/QUICKSTART.md b/web/vendor/docs/QUICKSTART.md new file mode 100644 index 0000000..95781e7 --- /dev/null +++ b/web/vendor/docs/QUICKSTART.md @@ -0,0 +1,112 @@ +# Orby — quick start + +**A procedural glyph engine by Joan Sterjo.** + +The download kit is self-contained. Start with the path that matches how you +want to use the engine; no registry download or third-party runtime dependency +is required. + +## 1. Open the standalone Studio + +Open `joan-engine-v5.standalone.html` in a modern browser. It contains the +Studio, engine modules, and styles in one file, so it works without a build +step or local server. Use this path to explore states, tune a recipe, and export +PNG, SVG, animated SVG, or `.joan.json` output. + +## 2. Import the native ES modules + +Serve the extracted folder from a local HTTP server: + +```sh +cd joan-procedural-glyph-engine-5.0.0 +python3 -m http.server 4173 +``` + +Then import the engine directly from the included source: + +```html + + +``` + +Open `examples/canvas.html` for a complete interactive version. + +## 3. Install the extracted folder locally + +From an application beside the extracted kit: + +```sh +npm install ./joan-procedural-glyph-engine-5.0.0 +``` + +Use the package and its TypeScript declarations through the included exports: + +```js +import { createGlyph } from "@joan/procedural-glyph-engine"; +import { playStateSequence } from + "@joan/procedural-glyph-engine/state-sequence"; + +const glyph = createGlyph("#ai-glyph", { + sprite: "ai.ambient-idle", + seed: "conversation-42", + gridSize: 68, +}); + +const playback = playStateSequence(glyph, [ + { sprite: "ai.ambient-idle", holdMs: 5_000 }, + { sprite: "ai.progress", transition: "contour-trace" }, + { sprite: "status.success", transition: "path-draw" }, +]); + +await playback.finished; +``` + +## Web component + +Import `src/web-component-register.js`, then use `` anywhere in the +page. Its intrinsic default is 68 × 68 CSS pixels. + +```html + + +``` + +Open `examples/web-component.html` for a runnable example. + +## Runtime essentials + +- `glyph.transitionTo(sprite, options)` performs and awaits a visual handoff. +- `glyph.setSprite(sprite)` changes state immediately and remains chainable. +- `glyph.setOptions(patch)` updates the live engine safely. +- `glyph.signal(type, payload)` injects short-lived product input. +- `glyph.exportConfig()` returns a JSON-safe recipe. +- `glyph.toSVG()` and `glyph.downloadPNG()` create portable output. +- `glyph.destroy()` releases animation frames, observers, and listeners. + +See `README.md` for the complete state catalog, configuration surface, API, +events, custom fields and glyphs, orchestration, exports, performance guidance, +accessibility, and testing. + +## Verify the kit + +`SHA256SUMS.txt` lists every payload file included in the archive. The website also +publishes a checksum beside the ZIP so the archive itself can be verified +before extraction. + +Orby is source-available under the Apache License 2.0 subject to the Commons +Clause License Condition v1.0. You may use, copy, modify, and redistribute it, +but may not sell Orby or a product or service whose value derives entirely or +substantially from Orby's functionality. See `LICENSE` and `NOTICE` in the kit. diff --git a/web/vendor/docs/assets/readme-hero.svg b/web/vendor/docs/assets/readme-hero.svg new file mode 100644 index 0000000..19c51c8 --- /dev/null +++ b/web/vendor/docs/assets/readme-hero.svg @@ -0,0 +1,162 @@ + + Orby — A procedural glyph engine by Joan Sterjo. + A monochrome editorial banner for Orby, a procedural glyph engine by Joan Sterjo, showing a procedural pixel orb and engine statistics: 25 semantic states, 19 fields, 12 transitions, and 9 pixel shapes. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SYSTEM / 05 + DETERMINISTIC · LIVE · PORTABLE + V5.0.0 + + + + ORBY + A PROCEDURAL GLYPH ENGINE BY JOAN STERJO. + + + A living signal for every + state of intelligence. + + + + Deterministic pixel motion for expressive AI states, + responsive transitions, and coherent product presence. + + + + + + + LIVE FIELD / 68×68 + STATE / AI.IDLE + + + + + 25 + SEMANTIC STATES + + + 19 + FIELDS + + + 12 + TRANSITIONS + + + 9 + PIXEL SHAPES + + + diff --git a/web/vendor/examples/README.md b/web/vendor/examples/README.md new file mode 100644 index 0000000..63bd2dd --- /dev/null +++ b/web/vendor/examples/README.md @@ -0,0 +1,18 @@ +# Orby runnable examples + +Serve the extracted download folder over HTTP, then open one of these files: + +- `canvas.html` mounts the native canvas engine and invokes semantic states. +- `web-component.html` uses the browser-native `` element. +- `state-sequence.html` runs, pauses, resumes, and restarts a timed sequence. + +For example: + +```sh +python3 -m http.server 4173 +``` + +Then visit `http://127.0.0.1:4173/examples/canvas.html`. + +Every import is relative to the files included in the kit. No package registry, +bundler, framework, or third-party dependency is required. diff --git a/web/vendor/examples/canvas.html b/web/vendor/examples/canvas.html new file mode 100644 index 0000000..5969a17 --- /dev/null +++ b/web/vendor/examples/canvas.html @@ -0,0 +1,50 @@ + + + + + + Orby · Canvas example + + + +
+

Native canvas

+

+ One long-lived engine instance moves between semantic states while + preserving its seeded visual identity. +

+
+ +
+
+ + + + +
+

Current state: ai.idle

+
+ + + + diff --git a/web/vendor/examples/example.css b/web/vendor/examples/example.css new file mode 100644 index 0000000..38e109f --- /dev/null +++ b/web/vendor/examples/example.css @@ -0,0 +1,90 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + background: #050505; + color: #f2f1ee; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + display: grid; + place-items: center; + background: + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px), + #050505; + background-size: 24px 24px; +} + +main { + width: min(680px, calc(100% - 32px)); + padding: 32px; + border: 1px solid #2b2b2d; + border-radius: 14px; + background: #0b0b0c; +} + +h1 { + margin: 0; + font-size: clamp(2rem, 7vw, 4rem); + font-weight: 300; + letter-spacing: -0.055em; +} + +p { + max-width: 52ch; + color: #9b9b9f; + line-height: 1.65; +} + +.glyph-stage { + min-height: 320px; + margin-top: 28px; + display: grid; + place-items: center; + border: 1px solid #232426; + border-radius: 10px; + background: #070708; +} + +canvas, +joan-glyph { + width: 220px; + height: 220px; + display: block; +} + +.actions { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +button { + min-height: 42px; + padding: 0 14px; + border: 1px solid #35373a; + border-radius: 7px; + background: #0f1011; + color: #e8e8e4; + font: 600 0.72rem ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +button:hover, +button:focus-visible { + border-color: #f2f1ee; + outline: 0; +} + +[role="status"] { + min-height: 1.4em; + font: 0.78rem ui-monospace, SFMono-Regular, Menlo, monospace; +} diff --git a/web/vendor/examples/state-sequence.html b/web/vendor/examples/state-sequence.html new file mode 100644 index 0000000..8148e8c --- /dev/null +++ b/web/vendor/examples/state-sequence.html @@ -0,0 +1,84 @@ + + + + + + Orby · State sequence example + + + +
+

Timed sequence

+

+ Define a product-owned timeline, then pause, resume, stop, or replay it + through one controller. +

+
+ +
+
+ + + + +
+

Sequence ready

+
+ + + + diff --git a/web/vendor/examples/web-component.html b/web/vendor/examples/web-component.html new file mode 100644 index 0000000..32632f4 --- /dev/null +++ b/web/vendor/examples/web-component.html @@ -0,0 +1,48 @@ + + + + + + Orby · Web component example + + + + +
+

Web component

+

+ Drop the dependency-free custom element into a page, then drive its + attributes or call the forwarded engine methods. +

+
+ +
+
+ + + +
+

Current state: ai.ambient-thinking

+
+ + + + diff --git a/web/vendor/joan-engine-v5.standalone.html b/web/vendor/joan-engine-v5.standalone.html new file mode 100644 index 0000000..4750427 --- /dev/null +++ b/web/vendor/joan-engine-v5.standalone.html @@ -0,0 +1,5592 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Orby — A procedural glyph engine by Joan Sterjo + + + + + + + + +
+
+ + + + Orby + By Joan Sterjo + + v5.0 + + + + +
+ + + Engine ready + + + + + + +
+
+
+ +
+
+
+

System 05 Semantic motion infrastructure

+

A living signal for every state of intelligence.

+

+ A procedural glyph engine by Joan Sterjo. Orby turns product intent into + expressive, deterministic pixel motion. Shape one visual identity, invoke it + anywhere, and keep every transition responsive, reproducible, and alive. +

+ +
+ +
+ + +
+
+
25
+
semantic states
+
+
+
12
+
transition modes
+
+
+
68×68
+
default pixel field
+
+
+
+
+ +
+
+

Foundation / 01

+

Motion infrastructure, not a loading ornament.

+
+
+
+ +

Deterministic identity

+

+ A seed preserves the character of the field while signals and interaction + change its energy. +

+
+
+ +

Semantic states

+

+ Ready, thinking, tool use, completion, and recovery share one coherent + visual language. +

+
+
+ +

Portable invocation

+

+ Call one state or choreograph a timed sequence, then carry the same recipe + into any product surface. +

+
+
+
+ +
+
+

Live Studio / 02

+

Design the signal, then run it for real.

+
+

+ Stage a semantic state, tune its field and motion, and export the result as a + reusable recipe. Every control below edits the live engine. +

+
+ +
+
+
+
+

Canvas / 01

+
+ Core state +

Idle

+
+

+ A calm, responsive presence with a low-frequency breathing field. +

+
+ +
+ Inject signal +
+ + + +
+
+
+ +
+
+ + + + 25 states available + +
+ +
+
+ Recipe base + Ready to customize +
+
+ + + + + +
+
+
+ +
+ + Your browser does not support the canvas required by Orby. + + + Live render + + + Move · press · drop a glyph + + + Fit fills the preview frame. 1:1 shows each engine grid cell as one + CSS pixel. + +
+ + +
+
+ + + +
+
+ Quick states +
+ + + + + + +
+
+ +
+
+
FPS
+
+
+
+
Pixels
+
+
+
+
State
+
ai.idle
+
+
+
+ +
+

Space motion states R reseed

+
+ + + + Automatically cycles through Ready, Listening, Thinking, Tool Use, + Generating, Speaking, and Completed every four seconds until stopped. + +
+
+
+ + +
+ +
+
+

Integration / 03

+

From a blank canvas to a product signal.

+

+ Create one long-lived glyph, drive it with product state, and carry the + same deterministic recipe wherever the interface needs presence. +

+
+ +
    +
  1. + +
    +

    Install and create

    +

    Download the kit, install the extracted folder, and mount one long-lived glyph.

    +
    npm install ./joan-procedural-glyph-engine-5.0.0
    +
    import { createGlyph } from "@joan/procedural-glyph-engine";
    +
    +const glyph = createGlyph("#ai-glyph", {
    +  sprite: "ai.idle",
    +  seed: "product-session",
    +  gridSize: 68
    +});
    +
    +
  2. +
  3. + +
    +

    Invoke intent

    +

    Move directly to a state or play a timed sequence as product work unfolds.

    +
    import { playStateSequence } from "@joan/procedural-glyph-engine/state-sequence";
    +
    +await glyph.transitionTo("ai.thinking");
    +
    +const playback = playStateSequence(glyph, [
    +  { sprite: "ai.idle", holdMs: 5_000 },
    +  { sprite: "status.success" }
    +]);
    +
    +await playback.finished;
    +
    +
  4. +
  5. + +
    +

    Export or embed

    +

    Keep the live canvas in your UI, or package its current identity for delivery.

    +
    const recipe = glyph.exportConfig();
    +const svg = glyph.toSVG();
    +
    +await glyph.downloadPNG("ai-state.png");
    +
    +
  6. +
+
+ +
+
+
+

State atlas / 04

+

A complete language for AI presence.

+

+ Twenty-five semantic sprites cover attention, ambient presence, + latency, tool work, transfer, completion, and recovery. +

+
+
+ 25 motion recipes + Select any state to stage it above +
+
+ +
+
+
+

Local collection

+

Saved presets

+

+ Reopen your tuned states here. Presets are stored locally in this + browser and do not sync between devices. +

+
+
+ + 0 presets + + +
+
+ +

+ No presets saved yet. Tune a state, then choose Save current. It will stay + available locally in this browser. +

+ + +
+ +
+
+ +
+
+

API / 05

+

From intent to pixels through one small runtime.

+

+ Ship the same state language across copilots, agents, system trays, + notifications, and ambient interfaces. The runtime is dependency-free and + keeps every parameter inspectable. Named sequences can be saved as data or + invoked inline with explicit timing. +

+ +
+
+
Runtime
+
Canvas 2D · ESM
+
+
+
Control
+
Sequences · signals · progress
+
+
+
Output
+
PNG · SVG · JSON recipe
+
+
+ +
+ + + + +
+
+ +
+
+
+ + glyph-sequence.js +
+ +
+
import { createGlyph } from "@joan/procedural-glyph-engine";
+import { playStateSequence } from "@joan/procedural-glyph-engine/state-sequence";
+
+const glyph = createGlyph("#ai-glyph", {
+  sprite: "ai.idle",
+  seed: "world-class-ai",
+  gridSize: 68
+});
+
+const playback = playStateSequence(glyph, [
+  { sprite: "ai.idle", holdMs: 5_000 },
+  { sprite: "status.success", transition: "path-draw" }
+]);
+
+await playback.finished;
+
+
+ +
+
+

Self service / 06

+

Everything you need to run Orby.

+

+ Take a versioned integration kit with the native ESM runtime, + TypeScript declarations, complete integration documentation, runnable + examples, and the offline Studio. +

+ +
    +
  • Native dependency-free runtime modules
  • +
  • TypeScript declarations for every export
  • +
  • Complete API and integration reference
  • +
  • Canvas, web component, and sequence examples
  • +
  • Single-file offline Studio
  • +
  • License and attribution notices (LICENSE and NOTICE)
  • +
+ +

+ Distribution status: SOURCE AVAILABLE. Orby is licensed + under + Apache License 2.0 subject to Commons Clause 1.0. + You may use, copy, modify, and redistribute Orby, but may not sell Orby + or a product or service whose value derives entirely or substantially + from Orby's functionality. +

+
+ +
+
+
+

Complete integration kit

+ Recommended +
+

Integration kit

+

+ Install from the extracted folder or import the source directly. + Checksums make the complete package independently verifiable. +

+

+ v5.0.0 + ZIP + Documentation included + SHA-256 verified +

+ +
+ +
+
+

No setup

+ HTML +
+

Standalone Studio

+

+ Open one self-contained HTML file to explore, tune, and export + glyphs without installing anything. +

+ +
+ +
+
+

Full reference

+ Markdown +
+

Documentation

+

+ API, web component, states, signals, sequences, exports, + accessibility, performance, and testing guidance. +

+ +
+
+
+
+ + + + +
+
+
+

Export preflight

+

Prepare the exact output.

+
+ +
+ +
+
+
+ +
+
+

Output preview

+

+ Export uses the fitted composition, independently of the Fit or 1:1 + inspection view. +

+
+
+ +
+ + + + + + + + +
+
+
Dimensions
+
512×512 px
+
+
+
Composition
+
Fit · full frame
+
+
+
Motion
+
Current frame
+
+
+
Background
+
Included
+
+
+
+
+ +
+ + +
+
+
+ +

+ + + + + + diff --git a/web/vendor/manifest.json b/web/vendor/manifest.json new file mode 100644 index 0000000..20232da --- /dev/null +++ b/web/vendor/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "@joan/procedural-glyph-engine", + "version": "5.0.0", + "license": "SEE LICENSE IN LICENSE", + "entry": "./src/joan-engine.js", + "demo": "./index.html", + "assets": [ + "./src/styles.css" + ], + "format": "esm" +} diff --git a/web/vendor/package.json b/web/vendor/package.json new file mode 100644 index 0000000..d93d2bd --- /dev/null +++ b/web/vendor/package.json @@ -0,0 +1,106 @@ +{ + "name": "@joan/procedural-glyph-engine", + "version": "5.0.0", + "description": "Orby — A procedural glyph engine by Joan Sterjo.", + "author": "Joan Sterjo", + "homepage": "https://joansterjo-celonis.github.io/Procedural-glyph-engine/", + "repository": { + "type": "git", + "url": "git+https://github.com/joansterjo-celonis/Procedural-glyph-engine.git" + }, + "bugs": { + "url": "https://github.com/joansterjo-celonis/Procedural-glyph-engine/issues" + }, + "type": "module", + "types": "./types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "import": "./src/joan-engine.js", + "default": "./src/joan-engine.js" + }, + "./config": { + "types": "./types/config.d.ts", + "import": "./src/config.js", + "default": "./src/config.js" + }, + "./fields": { + "types": "./types/fields.d.ts", + "import": "./src/fields.js", + "default": "./src/fields.js" + }, + "./glyphs": { + "types": "./types/glyphs.d.ts", + "import": "./src/glyphs.js", + "default": "./src/glyphs.js" + }, + "./sprites": { + "types": "./types/sprites.d.ts", + "import": "./src/sprites.js", + "default": "./src/sprites.js" + }, + "./state-director": { + "types": "./types/state-director.d.ts", + "import": "./src/state-director.js", + "default": "./src/state-director.js" + }, + "./state-sequence": { + "types": "./types/state-sequence.d.ts", + "import": "./src/state-sequence.js", + "default": "./src/state-sequence.js" + }, + "./web-component": { + "types": "./types/web-component.d.ts", + "import": "./src/web-component.js", + "default": "./src/web-component.js" + }, + "./web-component/register": { + "types": "./types/web-component-register.d.ts", + "import": "./src/web-component-register.js", + "default": "./src/web-component-register.js" + }, + "./styles.css": "./src/styles.css", + "./package.json": "./package.json" + }, + "sideEffects": [ + "./src/web-component.js", + "./src/web-component-register.js", + "./src/styles.css" + ], + "files": [ + "src", + "types", + "docs", + "examples", + "README.md", + "LICENSE", + "NOTICE", + "dist/manifest.json", + "dist/joan-engine-v5.standalone.html" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "node scripts/dev.mjs", + "build": "node scripts/build.mjs", + "test": "node --test", + "verify:download": "node scripts/verify-download.mjs", + "check": "npm run test && npm run build && npm run verify:download", + "prepack": "npm run check" + }, + "keywords": [ + "orby", + "canvas", + "glyph", + "pixel", + "procedural-animation", + "ai-status", + "sprite", + "dither" + ], + "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=18" + } +} diff --git a/web/vendor/src/config.js b/web/vendor/src/config.js new file mode 100644 index 0000000..55bdccc --- /dev/null +++ b/web/vendor/src/config.js @@ -0,0 +1,687 @@ +import { FIELD_ALIASES, FIELD_IDS } from "./fields.js"; +import { SPRITE_ALIASES, SPRITE_IDS } from "./sprites.js"; + +export const TRANSITION_IDS = Object.freeze([ + "field-morph", + "seeded-dissolve", + "radial-cascade", + "angular-sweep", + "scanline", + "contour-trace", + "path-draw", + "axis-flip", + "cluster-dissolve", + "neighbor-ignite", + "glitch-bands", + "instant", +]); + +export const PIXEL_SWITCH_IDS = Object.freeze([ + "ordered-dither", + "temporal-blue-noise", + "threshold-hysteresis", + "sdf-wavefront", + "contour-trace", + "curl-advect", + "neighbor-propagation", + "radial-cascade", + "path-draw", + "axis-flip", + "seeded-dissolve", + "field-morph", +]); + +export const PIXEL_SHAPE_IDS = Object.freeze([ + "disc", + "square", + "diamond", + "capsule", + "line", + "ring", + "cross", + "square-cross", + "square-cross-ring", +]); + +const TRANSITION_ALIASES = Object.freeze({ + dissolve: "seeded-dissolve", + radial: "radial-cascade", + bloom: "radial-cascade", + spiral: "angular-sweep", + wave: "scanline", + shutter: "axis-flip", + glitch: "glitch-bands", + "sdf-wavefront": "contour-trace", + "neighbor-propagation": "neighbor-ignite", +}); + +const PIXEL_SWITCH_ALIASES = Object.freeze({ + "cluster-dissolve": "seeded-dissolve", + "neighbor-ignite": "neighbor-propagation", +}); + +const PIXEL_SHAPE_ALIASES = Object.freeze({ + circle: "disc", + "rounded-square": "square", + dot: "disc", +}); + +const ENGINE_OPTION_KEYS = Object.freeze([ + "ariaLive", + "autoResize", + "autoplay", + "background", + "canvas", + "contrast", + "density", + "direction", + "dprMax", + "field", + "fieldMode", + "fps", + "gridSize", + "interactive", + "nonErrorPalette", + "offPixels", + "orbBoundary", + "orbBackgroundColor", + "orbBackgroundMode", + "package", + "palette", + "paletteOverride", + "pixelShape", + "pixelSwitch", + "previewMode", + "progress", + "quality", + "reducedMotion", + "seed", + "speed", + "sprite", + "targetGlyph", + "transition", + "version", +]); + +const NUMBER_OPTIONS = Object.freeze({ + contrast: { minimum: 0.25, maximum: 2 }, + density: { minimum: 0.35, maximum: 1.6 }, + dprMax: { minimum: 0.25, maximum: 8 }, + fps: { minimum: 1, maximum: 120, integer: true }, + gridSize: { minimum: 8, maximum: 96, integer: true }, + progress: { minimum: 0, maximum: 1 }, + speed: { minimum: 0.05, maximum: 5 }, +}); + +const BOOLEAN_OPTIONS = Object.freeze([ + "autoResize", + "autoplay", + "background", + "interactive", + "offPixels", +]); + +const normalizeKey = (value) => + String(value ?? "") + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-"); + +function distanceBetween(left, right) { + const a = [...String(left)]; + const b = [...String(right)]; + const previous = Array.from({ length: b.length + 1 }, (_, index) => index); + const current = new Array(b.length + 1); + + for (let row = 1; row <= a.length; row += 1) { + current[0] = row; + for (let column = 1; column <= b.length; column += 1) { + current[column] = Math.min( + current[column - 1] + 1, + previous[column] + 1, + previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1), + ); + } + for (let column = 0; column <= b.length; column += 1) { + previous[column] = current[column]; + } + } + return previous[b.length]; +} + +export function nearestName(value, candidates, options = {}) { + const input = normalizeKey(value); + if (!input || !Array.isArray(candidates) || candidates.length === 0) { + return null; + } + let nearest = null; + let nearestDistance = Infinity; + for (const candidate of candidates) { + const distance = distanceBetween(input, normalizeKey(candidate)); + if (distance < nearestDistance) { + nearest = candidate; + nearestDistance = distance; + } + } + const maximum = + options.maxDistance ?? Math.max(2, Math.floor(input.length * 0.34)); + return nearestDistance <= maximum ? nearest : null; +} + +export class JoanConfigurationError extends RangeError { + constructor(message, details = {}) { + super(message); + this.name = "JoanConfigurationError"; + this.code = details.code || "invalid_configuration"; + this.path = details.path || null; + this.value = details.value; + this.suggestion = details.suggestion || null; + this.allowed = details.allowed ? [...details.allowed] : null; + } + + toIssue() { + return { + code: this.code, + message: this.message, + path: this.path, + value: this.value, + suggestion: this.suggestion, + severity: "error", + }; + } +} + +function aliasLookup(aliases) { + return new Map( + Object.entries(aliases || {}).map(([alias, canonical]) => [ + normalizeKey(alias), + canonical, + ]), + ); +} + +export function validateKnownValue(value, allowed, options = {}) { + const label = options.label || "value"; + const path = options.path || label; + const normalized = normalizeKey(value); + const canonical = new Map( + allowed.map((candidate) => [normalizeKey(candidate), candidate]), + ); + if (canonical.has(normalized)) return canonical.get(normalized); + + const aliases = aliasLookup(options.aliases); + if (aliases.has(normalized)) return aliases.get(normalized); + + const accepted = [...allowed, ...Object.keys(options.aliases || {})]; + const suggestion = nearestName(value, accepted); + const suffix = suggestion ? ` Did you mean "${suggestion}"?` : ""; + throw new JoanConfigurationError( + `Unknown ${label} "${String(value)}".${suffix}`, + { + code: `unknown_${label.replaceAll(" ", "_")}`, + path, + value, + suggestion, + allowed, + }, + ); +} + +export const validateSpriteId = (value) => + validateKnownValue(value, SPRITE_IDS, { + aliases: SPRITE_ALIASES, + label: "sprite", + path: "sprite", + }); + +export const validateFieldId = (value) => + validateKnownValue(value, FIELD_IDS, { + aliases: FIELD_ALIASES, + label: "field", + path: "field", + }); + +export const validateTransition = (value) => + validateKnownValue( + typeof value === "string" + ? value + : value?.name || value?.type || value?.enter, + TRANSITION_IDS, + { + aliases: TRANSITION_ALIASES, + label: "transition", + path: "transition", + }, + ); + +export const validatePixelShape = (value) => + validateKnownValue( + typeof value === "string" ? value : value?.shape || value?.name, + PIXEL_SHAPE_IDS, + { + aliases: PIXEL_SHAPE_ALIASES, + label: "pixel shape", + path: "pixelShape", + }, + ); + +export const validatePixelSwitch = (value) => + validateKnownValue( + typeof value === "string" + ? value + : value?.mode || value?.name || value?.type, + PIXEL_SWITCH_IDS, + { + aliases: PIXEL_SWITCH_ALIASES, + label: "pixel switch", + path: "pixelSwitch", + }, + ); + +function issueFrom(error, fallbackPath) { + if (error instanceof JoanConfigurationError) return error.toIssue(); + return { + code: "invalid_configuration", + message: error?.message || "Invalid engine configuration.", + path: fallbackPath || null, + value: undefined, + suggestion: null, + severity: "error", + }; +} + +function inspectNumber(key, value, definition, coerce) { + if (!coerce && typeof value !== "number") { + throw new JoanConfigurationError(`${key} must be a number.`, { + code: "invalid_type", + path: key, + value, + }); + } + let next = coerce ? Number(value) : value; + if (!Number.isFinite(next)) { + throw new JoanConfigurationError(`${key} must be a finite number.`, { + code: "invalid_number", + path: key, + value, + }); + } + if (definition.integer && !coerce && !Number.isInteger(next)) { + throw new JoanConfigurationError(`${key} must be an integer.`, { + code: "invalid_number", + path: key, + value, + }); + } + if (definition.integer) next = Math.round(next); + if (next < definition.minimum || next > definition.maximum) { + if (!coerce) { + throw new JoanConfigurationError( + `${key} must be between ${definition.minimum} and ${definition.maximum}.`, + { + code: "out_of_range", + path: key, + value, + }, + ); + } + next = Math.min(Math.max(next, definition.minimum), definition.maximum); + } + return next; +} + +function validatePalette(value, path, { allowVariants = true } = {}) { + if (value === null) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new JoanConfigurationError(`${path} must be a palette object or null.`, { + code: "invalid_type", + path, + value, + }); + } + const allowed = new Set([ + "name", + "background", + "shadow", + "off", + "ink", + "accent", + "glow", + ...(allowVariants ? ["variants"] : []), + ]); + const next = {}; + for (const [key, color] of Object.entries(value)) { + if (!allowed.has(key)) { + const suggestion = nearestName(key, [...allowed]); + throw new JoanConfigurationError( + `Unknown ${path} channel "${key}".${ + suggestion ? ` Did you mean "${suggestion}"?` : "" + }`, + { + code: "unknown_palette_channel", + path: `${path}.${key}`, + value: color, + suggestion, + }, + ); + } + if (key === "variants") { + if (!color || typeof color !== "object" || Array.isArray(color)) { + throw new JoanConfigurationError(`${path}.variants must be an object.`, { + code: "invalid_type", + path: `${path}.variants`, + value: color, + }); + } + next.variants = Object.fromEntries( + Object.entries(color).map(([name, palette]) => [ + name, + validatePalette(palette, `${path}.variants.${name}`, { + allowVariants: false, + }), + ]), + ); + continue; + } + if (typeof color !== "string" || !color.trim()) { + throw new JoanConfigurationError(`${path}.${key} must be a CSS color string.`, { + code: "invalid_type", + path: `${path}.${key}`, + value: color, + }); + } + next[key] = color.trim(); + } + return next; +} + +const PORTABLE_COLOR_KEYWORDS = new Set([ + "aqua", + "black", + "blue", + "fuchsia", + "gray", + "green", + "grey", + "lime", + "maroon", + "navy", + "olive", + "orange", + "purple", + "red", + "silver", + "teal", + "transparent", + "white", + "yellow", +]); + +function validateOptionalColor(value, path) { + if (value === null) return null; + if (typeof value !== "string" || !value.trim()) { + throw new JoanConfigurationError(`${path} must be a CSS color string or null.`, { + code: "invalid_type", + path, + value, + }); + } + const candidate = value.trim(); + if (candidate.toLowerCase() === "transparent") return null; + const functionMatch = + /^(rgb|rgba|hsl|hsla)\(([\d\s.,%+/-]+)\)$/i.exec(candidate); + const portable = + /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate) || + PORTABLE_COLOR_KEYWORDS.has(candidate.toLowerCase()) || + (Boolean(functionMatch) && /\d/.test(functionMatch[2])); + if (!portable) { + throw new JoanConfigurationError( + `${path} must be a portable CSS color (hex, rgb, hsl, a supported keyword) or null.`, + { + code: "invalid_color", + path, + value, + }, + ); + } + return candidate; +} + +export function inspectEngineOptions(input, options = {}) { + const coerce = options.coerce !== false; + if (!input || typeof input !== "object" || Array.isArray(input)) { + const error = new JoanConfigurationError( + "Engine options must be an object.", + { code: "invalid_type", path: "options", value: input }, + ); + return { ok: false, value: {}, issues: [error.toIssue()] }; + } + + const value = { ...input }; + const issues = []; + const allowedKeys = new Set(ENGINE_OPTION_KEYS); + if (options.allowUnknown !== true) { + for (const key of Object.keys(value)) { + if (allowedKeys.has(key)) continue; + const suggestion = nearestName(key, ENGINE_OPTION_KEYS); + issues.push({ + code: "unknown_option", + message: `Unknown engine option "${key}".${ + suggestion ? ` Did you mean "${suggestion}"?` : "" + }`, + path: key, + value: value[key], + suggestion, + severity: "error", + }); + delete value[key]; + } + } + + const apply = (key, callback) => { + if (!Object.hasOwn(value, key) || value[key] === undefined) return; + try { + value[key] = callback(value[key]); + } catch (error) { + issues.push(issueFrom(error, key)); + delete value[key]; + } + }; + + apply("sprite", (sprite) => + sprite && typeof sprite === "object" ? validateRecipe(sprite) : validateSpriteId(sprite), + ); + apply("field", (field) => (field === null ? null : validateFieldId(field))); + apply("transition", (transition) => + transition === null + ? null + : transition && typeof transition === "object" + ? { + ...transition, + name: validateTransition( + transition.name || transition.type || transition.enter, + ), + } + : validateTransition(transition), + ); + apply("pixelShape", (shape) => + shape === null ? null : validatePixelShape(shape), + ); + apply("pixelSwitch", (pixelSwitch) => + pixelSwitch === null + ? null + : pixelSwitch && typeof pixelSwitch === "object" + ? { + ...pixelSwitch, + mode: validatePixelSwitch( + pixelSwitch.mode || pixelSwitch.name || pixelSwitch.type, + ), + } + : validatePixelSwitch(pixelSwitch), + ); + apply("fieldMode", (mode) => + validateKnownValue(mode, ["recipe", "override"], { + label: "field mode", + path: "fieldMode", + }), + ); + apply("quality", (quality) => + validateKnownValue(quality, ["low", "balanced", "high", "auto"], { + label: "quality", + path: "quality", + }), + ); + apply("previewMode", (mode) => + validateKnownValue(mode, ["fit", "actual"], { + label: "preview mode", + path: "previewMode", + }), + ); + apply("orbBoundary", (boundary) => + validateKnownValue(boundary, ["defined", "gestalt"], { + label: "orb boundary", + path: "orbBoundary", + }), + ); + apply("orbBackgroundColor", (color) => + validateOptionalColor(color, "orbBackgroundColor"), + ); + apply("orbBackgroundMode", (mode) => + validateKnownValue(mode, ["none", "solid", "pixelated"], { + label: "orb background mode", + path: "orbBackgroundMode", + }), + ); + apply("reducedMotion", (mode) => { + if (typeof mode === "boolean" || mode === "system") return mode; + throw new JoanConfigurationError( + 'reducedMotion must be true, false, or "system".', + { code: "invalid_type", path: "reducedMotion", value: mode }, + ); + }); + + for (const [key, definition] of Object.entries(NUMBER_OPTIONS)) { + apply(key, (number) => inspectNumber(key, number, definition, coerce)); + } + for (const key of BOOLEAN_OPTIONS) { + apply(key, (boolean) => { + if (typeof boolean === "boolean") return boolean; + if (coerce && (boolean === "true" || boolean === "false")) { + return boolean === "true"; + } + throw new JoanConfigurationError(`${key} must be a boolean.`, { + code: "invalid_type", + path: key, + value: boolean, + }); + }); + } + for (const key of ["palette", "paletteOverride", "nonErrorPalette"]) { + apply(key, (palette) => validatePalette(palette, key)); + } + + return { ok: issues.length === 0, value, issues }; +} + +export function validateEngineOptions(input, options = {}) { + const strict = options.strict !== false; + const result = inspectEngineOptions(input, { + ...options, + coerce: options.coerce ?? !strict, + }); + if (!result.ok && strict) { + const issue = result.issues[0]; + throw new JoanConfigurationError(issue.message, issue); + } + return result.value; +} + +export function validateRecipe(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new JoanConfigurationError("A recipe must be an object.", { + code: "invalid_type", + path: "recipe", + value: input, + }); + } + if (typeof input.id !== "string" || !input.id.trim()) { + throw new JoanConfigurationError("A recipe requires a non-empty id.", { + code: "missing_recipe_id", + path: "recipe.id", + value: input.id, + }); + } + if (typeof input.glyph !== "string" || !input.glyph.trim()) { + throw new JoanConfigurationError("A recipe requires a glyph id.", { + code: "missing_recipe_glyph", + path: "recipe.glyph", + value: input.glyph, + }); + } + + const recipe = { ...input, id: input.id.trim(), glyph: input.glyph.trim() }; + if (recipe.field !== undefined) recipe.field = validateFieldId(recipe.field); + if (Array.isArray(recipe.fieldMix)) { + recipe.fieldMix = recipe.fieldMix.map((layer, index) => { + if (!layer || typeof layer !== "object") { + throw new JoanConfigurationError(`recipe.fieldMix[${index}] must be an object.`, { + code: "invalid_type", + path: `recipe.fieldMix[${index}]`, + value: layer, + }); + } + return { ...layer, field: validateFieldId(layer.field) }; + }); + } else if (!recipe.field) { + throw new JoanConfigurationError("A recipe requires field or fieldMix.", { + code: "missing_recipe_field", + path: "recipe.field", + value: recipe.field, + }); + } + + if (recipe.pixelShape !== undefined) { + recipe.pixelShape = validatePixelShape(recipe.pixelShape); + } + if (recipe.pixel?.shape !== undefined) { + recipe.pixel = { + ...recipe.pixel, + shape: validatePixelShape(recipe.pixel.shape), + }; + } + if (recipe.pixelSwitch !== undefined) { + if (typeof recipe.pixelSwitch === "string") { + recipe.pixelSwitch = validatePixelSwitch(recipe.pixelSwitch); + } else if (recipe.pixelSwitch?.mode !== undefined) { + recipe.pixelSwitch = { + ...recipe.pixelSwitch, + mode: validatePixelSwitch(recipe.pixelSwitch.mode), + }; + } + } + if (recipe.transition !== undefined) { + if (typeof recipe.transition === "string") { + recipe.transition = validateTransition(recipe.transition); + } else if (recipe.transition && typeof recipe.transition === "object") { + const transition = { ...recipe.transition }; + for (const key of ["name", "type", "enter", "exit"]) { + if (transition[key] !== undefined) { + transition[key] = validateTransition(transition[key]); + } + } + recipe.transition = transition; + } + } + return recipe; +} + +function deepFreeze(value, seen = new WeakSet()) { + if (!value || typeof value !== "object" || seen.has(value)) return value; + seen.add(value); + for (const nested of Object.values(value)) deepFreeze(nested, seen); + return Object.freeze(value); +} + +export function defineRecipe(recipe) { + return deepFreeze(validateRecipe(recipe)); +} diff --git a/web/vendor/src/control-help.js b/web/vendor/src/control-help.js new file mode 100644 index 0000000..0b0fba4 --- /dev/null +++ b/web/vendor/src/control-help.js @@ -0,0 +1,207 @@ +export const FIELD_HELP = Object.freeze({ + __recipe__: + "Uses the selected state’s authored blend of fields, preserving its intended visual character.", + fbm: "Builds soft, organic variation by layering several scales of smooth gradient noise.", + ridged: + "Folds layered noise into sharp ridges and filaments for a more etched texture.", + "domain-warp": + "Distorts layered noise with two moving noise fields, creating fluid folds and swirls.", + curl: "Turns the curl magnitude of animated noise into turbulent, smoke-like density.", + flow: "Carries animated ribbons along a curl-noise direction for a flowing texture.", + worley: + "Creates bright cellular islands around moving feature points, separated by darker gaps.", + voronoi: + "Smoothly morphs between seeded cellular layouts while keeping their moving division lines crisp.", + plasma: + "Combines layered sine waves with noise-driven phase shifts for an energetic plasma pattern.", + interference: + "Overlaps circular waves from moving emitters to create traveling beats and bands.", + vortex: + "Spins noisy spiral arms around the center for a rotating whirlpool-like field.", + metaballs: + "Blends several seeded moving blobs so nearby forms merge into one liquid mass.", + caustics: + "Produces sharp cellular highlights that resemble refracted light moving across water.", + strata: + "Stacks noise-warped sediment-like bands with a directional slope and sharp edges.", + radar: + "Combines a rotating beam, concentric rings, and seeded target blips.", + constellation: + "Connects twinkling cellular stars with faint Voronoi filaments.", + liquid: + "Layers domain-warped waves and highlights into a gently moving water surface.", + electric: + "Generates repeating branching lightning channels with pulses and traveling sparks.", + ripple: "Sends noise-distorted concentric rings outward from the center.", + kaleidoscope: + "Folds rotating polar noise into mirrored, mandala-like symmetry.", +}); + +export const TRANSITION_HELP = Object.freeze({ + "field-morph": + "Morphs cells on a soft schedule combining seeded randomness, radius, and vertical position.", + "seeded-dissolve": + "Changes pixels in a stable, seed-determined random order.", + "radial-cascade": + "Moves the new state outward from the origin with slight seeded variation.", + "angular-sweep": + "Rotates the new state around the origin with a subtle outward offset.", + scanline: + "Reveals the new state from top to bottom in a lightly rippled scan.", + "contour-trace": + "Starts on a circular mid-radius contour, then spreads inward and outward.", + "path-draw": + "Draws the new state diagonally from the upper left toward the lower right.", + "axis-flip": + "Staggers alternating columns while advancing top to bottom, producing a shutter-like flip.", + "cluster-dissolve": + "Changes coarse pixel clusters in a seeded random order for a chunkier dissolve.", + "neighbor-ignite": + "Spreads the new state in a connected-looking wave from the origin.", + "glitch-bands": + "Switches horizontal bands in a scrambled order for a brief glitch effect.", + instant: "Applies the new state immediately with no spatial interpolation.", +}); + +export const PIXEL_SHAPE_HELP = Object.freeze({ + disc: "Draws each cell as a filled round dot.", + square: "Draws each cell as a filled square for a crisp grid texture.", + diamond: "Draws each cell as a compact square rotated 45 degrees.", + capsule: "Draws each cell as a rounded horizontal pill.", + line: "Draws each cell as a thin bar aligned tangentially around the center.", + ring: "Draws each cell as a hollow circular outline.", + cross: "Draws each cell as a compact plus sign.", + "square-cross": + "Uses filled squares for dim sampled signal cells and crosses for bright cells.", + "square-cross-ring": + "Uses filled squares, crosses, then rings as sampled signal lightness rises.", +}); + +export const PIXEL_SWITCH_HELP = Object.freeze({ + "ordered-dither": + "Uses a fixed Bayer pattern for stable, evenly distributed pixel activation.", + "temporal-blue-noise": + "Blends an ordered grid with time-varying noise for lively, fine-grained switching.", + "threshold-hysteresis": + "Uses a uniform midpoint threshold with hysteresis for calm, stable switching.", + "sdf-wavefront": + "Adds a moving radial wavefront to ordered thresholds so activation travels in rings.", + "contour-trace": + "Modulates thresholds along repeating field-value contours so similar intensity bands switch together.", + "curl-advect": + "Offsets ordered thresholds with a traveling wave so activation appears to drift diagonally.", + "neighbor-propagation": + "Lowers a cell’s threshold when adjacent cells are on, encouraging activation to spread locally.", + "radial-cascade": + "Biases ordered activation from the center outward.", + "path-draw": + "Biases ordered activation along a top-left-to-bottom-right path.", + "axis-flip": + "Staggers alternating columns and compresses cells vertically as they turn on, creating a flip.", + "seeded-dissolve": + "Gives each cell a stable, seed-determined random threshold for a repeatable dissolve texture.", + "field-morph": + "Blends ordered and seed-random thresholds for both structure and organic variation.", +}); + +export const SELECT_CONTROL_HELP = Object.freeze({ + fieldSelect: Object.freeze({ + summary: "Chooses the procedural texture sampled inside the glyph.", + options: FIELD_HELP, + }), + transitionSelect: Object.freeze({ + summary: + "Controls the spatial order used when moving between semantic states.", + options: TRANSITION_HELP, + }), + pixelShapeSelect: Object.freeze({ + summary: "Changes the silhouette used to draw each active cell.", + options: PIXEL_SHAPE_HELP, + }), + pixelSwitchSelect: Object.freeze({ + summary: + "Controls how continuous field intensity becomes persistent on/off pixels.", + options: PIXEL_SWITCH_HELP, + }), + orbBackgroundModeSelect: Object.freeze({ + summary: "Chooses how the optional orb background disk is constructed.", + options: Object.freeze({ + solid: + "Fills one smooth circle behind the orb while preserving the grid above it.", + pixelated: + "Builds the disk from full grid-aligned cells so its edge matches the selected resolution.", + }), + }), +}); + +export const VALUE_CONTROL_HELP = Object.freeze({ + resolutionRange: + "Sets the number of cells per side. Higher values add detail but increase rendering work roughly with the square.", + speedRange: + "Multiplies the procedural animation clock, making textures and sprite motion run slower or faster.", + densityRange: + "Biases activation so higher values keep more pixels on and lower values make the signal sparser.", + seedInput: + "A stable text key that determines noise and per-cell randomness. Reuse it with the same settings to reproduce the signal.", + randomizeButton: + "Creates a new seed and a new deterministic variation of the current signal.", + backgroundColorInput: + "Sets the canvas fill behind the grid across non-error states.", + offColorInput: + "Sets the faint color of grid cells that are currently off across non-error states.", + inkColorInput: + "Sets the base color of active pixels across non-error states.", + accentColorInput: + "Sets the color active pixels move toward as sampled luminance rises.", + glowEnabledInput: + "Turns the halo around active pixels on or off across non-error states.", + orbBoundaryInput: + "Defined draws the authored rim. Gestalt removes the continuous outline and lets clustered edge pixels suggest the circle through proximity, closure, and continuing arcs.", + orbBackgroundEnabledInput: + "Adds or removes the optional orb background layer in supported presence orbs. It is separate from the canvas background.", + orbBackgroundColorInput: + "Sets the optional color behind supported orb pixels. The color carries across states but stays visually inert for non-orb recipes.", + glowColorInput: + "Sets the halo around active pixels. Glow renders in high quality at resolutions up to 48×48.", + resetPaletteButton: + "Clears custom color overrides and returns non-error states to the current theme’s defaults.", +}); + +const clamp = (value, minimum, maximum) => + Math.min(Math.max(value, minimum), maximum); + +export function placeControlTooltip( + anchor, + tooltip, + viewport, + options = {}, +) { + const gap = Number(options.gap) || 8; + const gutter = Number(options.gutter) || 12; + const maximumLeft = Math.max( + gutter, + Number(viewport.width) - Number(tooltip.width) - gutter, + ); + const centeredLeft = + Number(anchor.left) + + Number(anchor.width) / 2 - + Number(tooltip.width) / 2; + const left = clamp(centeredLeft, gutter, maximumLeft); + const below = Number(anchor.bottom) + gap; + const above = Number(anchor.top) - Number(tooltip.height) - gap; + const fitsBelow = + below + Number(tooltip.height) <= Number(viewport.height) - gutter; + const fitsAbove = above >= gutter; + const maximumTop = Math.max( + gutter, + Number(viewport.height) - Number(tooltip.height) - gutter, + ); + const placement = !fitsBelow && fitsAbove ? "top" : "bottom"; + const preferredTop = placement === "top" ? above : below; + + return { + left: Math.round(left), + top: Math.round(clamp(preferredTop, gutter, maximumTop)), + placement, + }; +} diff --git a/web/vendor/src/fields.js b/web/vendor/src/fields.js new file mode 100644 index 0000000..b5101a5 --- /dev/null +++ b/web/vendor/src/fields.js @@ -0,0 +1,1323 @@ +/** + * Deterministic scalar fields for Orby. + * + * Coordinate convention + * --------------------- + * `x` and `y` are continuous field-space coordinates (normally normalized to + * -1..1 across a centered glyph), and `time` is an arbitrary monotonically + * increasing value, conventionally seconds. Every named field returns a finite + * value in 0..1. Options are optional plain objects and are never mutated. + * + * The hot sampling path is allocation-free. `FieldKit` owns its permutation + * table and a few scalar scratch values; pass an output array to `curl2` when + * calling that vector helper in an animation loop. + */ + +export const TAU = Math.PI * 2; +export const BAYER8_SIZE = 8; + +/** Clamp `value` to an inclusive range. Non-finite values become `min`. */ +export function clamp(value, min = 0, max = 1) { + if (!Number.isFinite(value)) return Number.isFinite(min) ? min : 0; + if (value < min) return min; + if (value > max) return max; + return value; +} + +/** Linear interpolation without implicit clamping. */ +export function lerp(a, b, amount) { + return a + (b - a) * amount; +} + +/** Hermite interpolation from 0 to 1 between `edge0` and `edge1`. */ +export function smoothstep(edge0, edge1, value) { + if (!Number.isFinite(value)) return 0; + if (edge0 === edge1) return value < edge0 ? 0 : 1; + const t = clamp((value - edge0) / (edge1 - edge0)); + return t * t * (3 - 2 * t); +} + +/** Quintic interpolation from 0 to 1 between `edge0` and `edge1`. */ +export function smootherstep(edge0, edge1, value) { + if (!Number.isFinite(value)) return 0; + if (edge0 === edge1) return value < edge0 ? 0 : 1; + const t = clamp((value - edge0) / (edge1 - edge0)); + return t * t * t * (t * (t * 6 - 15) + 10); +} + +/** Positive fractional part, including for negative inputs. */ +export function fract(value) { + if (!Number.isFinite(value)) return 0; + return value - Math.floor(value); +} + +/** Standard 8x8 Bayer ordered-dither matrix, containing each value 0..63. */ +export const BAYER8 = Object.freeze([ + Object.freeze([0, 48, 12, 60, 3, 51, 15, 63]), + Object.freeze([32, 16, 44, 28, 35, 19, 47, 31]), + Object.freeze([8, 56, 4, 52, 11, 59, 7, 55]), + Object.freeze([40, 24, 36, 20, 43, 27, 39, 23]), + Object.freeze([2, 50, 14, 62, 1, 49, 13, 61]), + Object.freeze([34, 18, 46, 30, 33, 17, 45, 29]), + Object.freeze([10, 58, 6, 54, 9, 57, 5, 53]), + Object.freeze([42, 26, 38, 22, 41, 25, 37, 21]), +]); + +/** Return the centered Bayer threshold for an integer pixel coordinate. */ +export function bayer8(x, y) { + const ix = Number.isFinite(x) ? Math.floor(x) & 7 : 0; + const iy = Number.isFinite(y) ? Math.floor(y) & 7 : 0; + return (BAYER8[iy][ix] + 0.5) / 64; +} + +export const bayerThreshold = bayer8; + +/** + * Quantize a 0..1 value with ordered dithering. + * `levels=2` produces a binary pixel switch; larger values produce terraces. + */ +export function orderedDither(value, x, y, levels = 2) { + const count = clamp(Math.floor(levels), 2, 256); + const scaled = clamp(value) * (count - 1); + const low = Math.floor(scaled); + const high = Math.min(count - 1, low + 1); + const mix = scaled - low; + return (mix > bayer8(x, y) ? high : low) / (count - 1); +} + +/** Stable 32-bit hash for numeric, string, bigint, boolean, or null seeds. */ +export function hashSeed(seed = 0) { + let hash = 0x811c9dc5; + + if (typeof seed === "number" && Number.isFinite(seed)) { + if (Number.isInteger(seed)) { + hash ^= seed >>> 0; + hash = Math.imul(hash, 0x01000193); + } else { + const text = String(seed); + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + } + } else { + const text = typeof seed === "string" ? seed : String(seed ?? 0); + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + } + + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x7feb352d); + hash ^= hash >>> 15; + hash = Math.imul(hash, 0x846ca68b); + hash ^= hash >>> 16; + return hash >>> 0; +} + +const EMPTY_OPTIONS = Object.freeze({}); +const SQRT2 = Math.SQRT2; +const INV_255 = 1 / 255; + +function finite(value, fallback = 0) { + return Number.isFinite(value) ? value : fallback; +} + +function option(options, key, fallback, min = -Infinity, max = Infinity) { + if (!options) return fallback; + const value = options[key]; + if (!Number.isFinite(value)) return fallback; + return clamp(value, min, max); +} + +function optionInt(options, key, fallback, min, max) { + return Math.floor(option(options, key, fallback, min, max)); +} + +function contrast(value, amount) { + return clamp((value - 0.5) * amount + 0.5); +} + +function fade(value) { + return value * value * value * (value * (value * 6 - 15) + 10); +} + +function grad2(hash, x, y) { + switch (hash & 7) { + case 0: return x; + case 1: return -x; + case 2: return y; + case 3: return -y; + case 4: return (x + y) * Math.SQRT1_2; + case 5: return (-x + y) * Math.SQRT1_2; + case 6: return (x - y) * Math.SQRT1_2; + default: return (-x - y) * Math.SQRT1_2; + } +} + +function grad3(hash, x, y, z) { + const h = hash & 15; + const u = h < 8 ? x : y; + const v = h < 4 ? y : h === 12 || h === 14 ? x : z; + return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v); +} + +function ensureKit(kit) { + return kit instanceof FieldKit ? kit : DEFAULT_FIELD_KIT; +} + +/** + * Seeded noise and named-field sampler. + * + * Construct once and reuse: + * `const fields = new FieldKit("product-icon");` + * `const alpha = fields.sample("electric", x, y, seconds);` + */ +export class FieldKit { + constructor(seed = 0) { + const initialSeed = + seed && typeof seed === "object" && "seed" in seed ? seed.seed : seed; + + this.seed = hashSeed(initialSeed); + this.permutation = new Uint8Array(512); + + // Scalar scratch state keeps cellular/curl samplers allocation-free. + this._cellF1 = 0; + this._cellF2 = 0; + this._cellHash = 0; + this._curlX = 0; + this._curlY = 0; + this._curlMagnitude = 0; + + this._buildPermutation(); + } + + /** Rebuild the lookup table from a new seed and return this instance. */ + reseed(seed = 0) { + this.seed = hashSeed(seed); + this._buildPermutation(); + return this; + } + + setSeed(seed = 0) { + return this.reseed(seed); + } + + /** Create an independent sampler with the same seed. */ + clone() { + const copy = new FieldKit(0); + copy.seed = this.seed; + copy.permutation.set(this.permutation); + return copy; + } + + /** Frozen list of canonical named scalar fields. */ + list() { + return FIELD_IDS; + } + + has(id) { + return resolveFieldId(id) !== null; + } + + /** + * Return the standalone sampler function for an ID, or undefined. + * Standalone samplers accept `(x, y, time, options, kit)`. + */ + get(id) { + const resolved = resolveFieldId(id); + return resolved === null ? undefined : FIELDS[resolved]; + } + + /** Sample a canonical field or alias. Unknown IDs safely return 0. */ + sample(id, x, y, time = 0, options = EMPTY_OPTIONS) { + const resolved = resolveFieldId(id); + if (resolved === null) return 0; + const value = FIELDS[resolved]( + finite(x), + finite(y), + finite(time), + options || EMPTY_OPTIONS, + this, + ); + return clamp(value); + } + + /** + * Seeded lattice hash in 0..1. Useful for deterministic sprite decisions. + * Inputs are treated as integer lattice coordinates. + */ + hash2(x, y, salt = 0) { + const p = this.permutation; + const xi = finite(Math.floor(x)) & 255; + const yi = finite(Math.floor(y)) & 255; + const si = finite(Math.floor(salt)) & 255; + return p[xi + p[yi + p[si]]] * INV_255; + } + + /** Signed 2D gradient noise in -1..1. */ + signedNoise2(x, y) { + return this._signedNoise2(finite(x), finite(y)); + } + + /** 2D gradient noise remapped to 0..1. */ + noise2(x, y) { + return this._signedNoise2(finite(x), finite(y)) * 0.5 + 0.5; + } + + /** Signed 3D gradient noise in -1..1; use z as animation time. */ + signedNoise3(x, y, z) { + return this._signedNoise3(finite(x), finite(y), finite(z)); + } + + /** 3D gradient noise remapped to 0..1. */ + noise3(x, y, z) { + return this._signedNoise3(finite(x), finite(y), finite(z)) * 0.5 + 0.5; + } + + /** Seeded smooth value noise in 0..1. */ + value2(x, y) { + x = finite(x); + y = finite(y); + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const tx = fade(x - x0); + const ty = fade(y - y0); + const p = this.permutation; + const ax = x0 & 255; + const ay = y0 & 255; + const bx = (x0 + 1) & 255; + const by = (y0 + 1) & 255; + const a = p[ax + p[ay]] * INV_255; + const b = p[bx + p[ay]] * INV_255; + const c = p[ax + p[by]] * INV_255; + const d = p[bx + p[by]] * INV_255; + return clamp(lerp(lerp(a, b, tx), lerp(c, d, tx), ty)); + } + + /** Fractal gradient noise in 0..1. */ + fbmNoise(x, y, z = 0, options = EMPTY_OPTIONS) { + const octaves = optionInt(options, "octaves", 5, 1, 9); + const lacunarity = option(options, "lacunarity", 2, 1.01, 4); + const gain = option(options, "gain", 0.5, 0.05, 0.95); + return this._fbmSigned( + finite(x), + finite(y), + finite(z), + octaves, + lacunarity, + gain, + ) * 0.5 + 0.5; + } + + /** Multi-octave ridged noise in 0..1. */ + ridgedNoise(x, y, z = 0, options = EMPTY_OPTIONS) { + return this._ridged( + finite(x), + finite(y), + finite(z), + optionInt(options, "octaves", 5, 1, 9), + option(options, "lacunarity", 2.05, 1.01, 4), + option(options, "gain", 0.52, 0.05, 0.95), + ); + } + + /** + * Normalized curl vector of a scalar noise potential. + * Supply `out` (array or typed array) to avoid the one fallback allocation. + */ + curl2(x, y, z = 0, epsilon = 0.0125, out) { + this._setCurl( + finite(x), + finite(y), + finite(z), + clamp(finite(epsilon, 0.0125), 0.0001, 0.25), + ); + const target = out || new Float32Array(2); + target[0] = this._curlX; + target[1] = this._curlY; + return target; + } + + /** Nearest animated Worley feature distance, normalized to 0..1. */ + cellular2(x, y, time = 0, options = EMPTY_OPTIONS) { + this._setCellular( + finite(x), + finite(y), + finite(time), + option(options, "jitter", 0.88, 0, 1), + option(options, "motion", 0.1, 0, 0.45), + ); + return clamp(Math.sqrt(this._cellF1) / SQRT2); + } + + /** Difference between smoothly seed-morphed Voronoi features, in 0..1. */ + voronoi2(x, y, time = 0, options = EMPTY_OPTIONS) { + this._setCellular( + finite(x), + finite(y), + finite(time) + option(options, "phase", 0, -10000, 10000), + option(options, "jitter", 0.88, 0, 1), + option(options, "motion", 0.16, 0, 0.45), + option(options, "seedRate", 0.9, 0, 8), + ); + return clamp((Math.sqrt(this._cellF2) - Math.sqrt(this._cellF1)) / SQRT2); + } + + // Named convenience methods mirror `sample` without string lookup. + fbm(x, y, time = 0, options) { + return fbm(x, y, time, options, this); + } + + ridged(x, y, time = 0, options) { + return ridged(x, y, time, options, this); + } + + domainWarp(x, y, time = 0, options) { + return domainWarp(x, y, time, options, this); + } + + curl(x, y, time = 0, options) { + return curl(x, y, time, options, this); + } + + flow(x, y, time = 0, options) { + return flow(x, y, time, options, this); + } + + worley(x, y, time = 0, options) { + return worley(x, y, time, options, this); + } + + voronoi(x, y, time = 0, options) { + return voronoi(x, y, time, options, this); + } + + plasma(x, y, time = 0, options) { + return plasma(x, y, time, options, this); + } + + interference(x, y, time = 0, options) { + return interference(x, y, time, options, this); + } + + vortex(x, y, time = 0, options) { + return vortex(x, y, time, options, this); + } + + metaballs(x, y, time = 0, options) { + return metaballs(x, y, time, options, this); + } + + caustics(x, y, time = 0, options) { + return caustics(x, y, time, options, this); + } + + strata(x, y, time = 0, options) { + return strata(x, y, time, options, this); + } + + radar(x, y, time = 0, options) { + return radar(x, y, time, options, this); + } + + constellation(x, y, time = 0, options) { + return constellation(x, y, time, options, this); + } + + liquid(x, y, time = 0, options) { + return liquid(x, y, time, options, this); + } + + electric(x, y, time = 0, options) { + return electric(x, y, time, options, this); + } + + ripple(x, y, time = 0, options) { + return ripple(x, y, time, options, this); + } + + kaleidoscope(x, y, time = 0, options) { + return kaleidoscope(x, y, time, options, this); + } + + _buildPermutation() { + const p = this.permutation; + for (let i = 0; i < 256; i += 1) p[i] = i; + + let state = this.seed || 0x6d2b79f5; + for (let i = 255; i > 0; i -= 1) { + state += 0x6d2b79f5; + let random = state; + random = Math.imul(random ^ (random >>> 15), random | 1); + random ^= random + Math.imul(random ^ (random >>> 7), random | 61); + random = (random ^ (random >>> 14)) >>> 0; + const j = Math.floor((random / 0x100000000) * (i + 1)); + const swap = p[i]; + p[i] = p[j]; + p[j] = swap; + } + + for (let i = 0; i < 256; i += 1) p[i + 256] = p[i]; + } + + _signedNoise2(x, y) { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const xf = x - x0; + const yf = y - y0; + const u = fade(xf); + const v = fade(yf); + const p = this.permutation; + const xi = x0 & 255; + const yi = y0 & 255; + + const aa = p[xi + p[yi]]; + const ba = p[xi + 1 + p[yi]]; + const ab = p[xi + p[yi + 1]]; + const bb = p[xi + 1 + p[yi + 1]]; + + const low = lerp(grad2(aa, xf, yf), grad2(ba, xf - 1, yf), u); + const high = lerp( + grad2(ab, xf, yf - 1), + grad2(bb, xf - 1, yf - 1), + u, + ); + return clamp(lerp(low, high, v) * 1.55, -1, 1); + } + + _signedNoise3(x, y, z) { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const z0 = Math.floor(z); + const xf = x - x0; + const yf = y - y0; + const zf = z - z0; + const u = fade(xf); + const v = fade(yf); + const w = fade(zf); + const p = this.permutation; + const xi = x0 & 255; + const yi = y0 & 255; + const zi = z0 & 255; + + const a = p[xi] + yi; + const aa = p[a] + zi; + const ab = p[a + 1] + zi; + const b = p[xi + 1] + yi; + const ba = p[b] + zi; + const bb = p[b + 1] + zi; + + const zLow = lerp( + lerp( + grad3(p[aa], xf, yf, zf), + grad3(p[ba], xf - 1, yf, zf), + u, + ), + lerp( + grad3(p[ab], xf, yf - 1, zf), + grad3(p[bb], xf - 1, yf - 1, zf), + u, + ), + v, + ); + const zHigh = lerp( + lerp( + grad3(p[aa + 1], xf, yf, zf - 1), + grad3(p[ba + 1], xf - 1, yf, zf - 1), + u, + ), + lerp( + grad3(p[ab + 1], xf, yf - 1, zf - 1), + grad3(p[bb + 1], xf - 1, yf - 1, zf - 1), + u, + ), + v, + ); + return clamp(lerp(zLow, zHigh, w) * 0.94, -1, 1); + } + + _fbmSigned(x, y, z, octaves, lacunarity, gain) { + let sum = 0; + let amplitude = 0.5; + let normalization = 0; + + for (let octave = 0; octave < octaves; octave += 1) { + sum += this._signedNoise3(x, y, z) * amplitude; + normalization += amplitude; + + // Rotate and offset between octaves to suppress axial lattice artifacts. + const nextX = (x * 0.8 - y * 0.6) * lacunarity + 17.17; + y = (x * 0.6 + y * 0.8) * lacunarity - 9.23; + x = nextX; + z = z * lacunarity + 5.71; + amplitude *= gain; + } + + return normalization > 0 ? clamp(sum / normalization, -1, 1) : 0; + } + + _ridged(x, y, z, octaves, lacunarity, gain) { + let sum = 0; + let amplitude = 0.5; + let normalization = 0; + let weight = 1; + + for (let octave = 0; octave < octaves; octave += 1) { + let ridge = 1 - Math.abs(this._signedNoise3(x, y, z)); + ridge *= ridge; + ridge *= weight; + weight = clamp(ridge * 2.25); + sum += ridge * amplitude; + normalization += amplitude; + + const nextX = (x * 0.764 - y * 0.645) * lacunarity + 11.37; + y = (x * 0.645 + y * 0.764) * lacunarity + 3.19; + x = nextX; + z = z * lacunarity - 4.31; + amplitude *= gain; + } + + return normalization > 0 ? clamp(sum / normalization) : 0; + } + + _setCurl(x, y, z, epsilon) { + const dY = + (this._signedNoise3(x, y + epsilon, z) - + this._signedNoise3(x, y - epsilon, z)) / + (epsilon * 2); + const dX = + (this._signedNoise3(x + epsilon, y, z) - + this._signedNoise3(x - epsilon, y, z)) / + (epsilon * 2); + const vx = dY; + const vy = -dX; + const magnitude = Math.hypot(vx, vy); + this._curlMagnitude = Number.isFinite(magnitude) ? magnitude : 0; + + if (magnitude > 1e-9 && Number.isFinite(magnitude)) { + this._curlX = vx / magnitude; + this._curlY = vy / magnitude; + } else { + this._curlX = 0; + this._curlY = 0; + } + } + + _setCellular(x, y, time, jitter, motion, seedRate) { + const baseX = Math.floor(x); + const baseY = Math.floor(y); + let f1 = Infinity; + let f2 = Infinity; + let nearestHash = 0; + const p = this.permutation; + const morphing = seedRate !== undefined && motion > 0; + const seedPhase = morphing ? time * seedRate : 0; + const seedEpoch = Math.floor(seedPhase); + const seedU = morphing ? fract(seedPhase) : 0; + const seedU2 = seedU * seedU; + const seedU3 = seedU2 * seedU; + const seedB0 = ((1 - seedU) * (1 - seedU) * (1 - seedU)) / 6; + const seedB1 = (4 - 6 * seedU2 + 3 * seedU3) / 6; + const seedB2 = (1 + 3 * seedU + 3 * seedU2 - 3 * seedU3) / 6; + const seedB3 = seedU3 / 6; + const seedSalt0 = morphing ? p[(seedEpoch - 1) & 255] : 0; + const seedSalt1 = morphing ? p[seedEpoch & 255] : 0; + const seedSalt2 = morphing ? p[(seedEpoch + 1) & 255] : 0; + const seedSalt3 = morphing ? p[(seedEpoch + 2) & 255] : 0; + const anchorScale = morphing ? Math.max(0, 1 - motion * 2) : 1; + + for (let oy = -1; oy <= 1; oy += 1) { + const cellY = baseY + oy; + const py = cellY & 255; + for (let ox = -1; ox <= 1; ox += 1) { + const cellX = baseX + ox; + const px = cellX & 255; + const hashA = p[px + p[py]]; + const hashB = p[px + p[py + 71]]; + const hashC = p[px + p[py + 149]]; + + let featureX = 0.5 + (hashA * INV_255 - 0.5) * jitter * anchorScale; + let featureY = 0.5 + (hashB * INV_255 - 0.5) * jitter * anchorScale; + if (morphing) { + const offsetX = + (p[hashA + seedSalt0] * seedB0 + + p[hashA + seedSalt1] * seedB1 + + p[hashA + seedSalt2] * seedB2 + + p[hashA + seedSalt3] * seedB3) * + INV_255 * + 2 - + 1; + const offsetY = + (p[hashB + seedSalt0] * seedB0 + + p[hashB + seedSalt1] * seedB1 + + p[hashB + seedSalt2] * seedB2 + + p[hashB + seedSalt3] * seedB3) * + INV_255 * + 2 - + 1; + featureX += offsetX * motion; + featureY += offsetY * motion; + } else { + const phase = hashC * INV_255 * TAU; + featureX += Math.sin(time + phase) * motion; + featureY += Math.cos(time * 0.91 + phase) * motion; + } + featureX = clamp(featureX, 0.015, 0.985); + featureY = clamp(featureY, 0.015, 0.985); + + const dx = cellX + featureX - x; + const dy = cellY + featureY - y; + const distance = dx * dx + dy * dy; + + if (distance < f1) { + f2 = f1; + f1 = distance; + nearestHash = hashC; + } else if (distance < f2) { + f2 = distance; + } + } + } + + this._cellF1 = Number.isFinite(f1) ? f1 : 0; + this._cellF2 = Number.isFinite(f2) ? f2 : this._cellF1; + this._cellHash = nearestHash; + } +} + +/** Soft multi-octave gradient noise. */ +export function fbm(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 2.35, 0.001, 256); + const speed = option(options, "speed", 0.18, -20, 20); + const value = + kit._fbmSigned( + finite(x) * frequency, + finite(y) * frequency, + finite(time) * speed + option(options, "phase", 0, -10000, 10000), + optionInt(options, "octaves", 5, 1, 9), + option(options, "lacunarity", 2, 1.01, 4), + option(options, "gain", 0.5, 0.05, 0.95), + ) * + 0.5 + + 0.5; + return contrast(value, option(options, "contrast", 1.08, 0, 8)); +} + +/** Sharp mountain/filament ridges with octave feedback. */ +export function ridged(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 2.7, 0.001, 256); + const value = kit._ridged( + finite(x) * frequency, + finite(y) * frequency, + finite(time) * option(options, "speed", 0.22, -20, 20), + optionInt(options, "octaves", 5, 1, 9), + option(options, "lacunarity", 2.04, 1.01, 4), + option(options, "gain", 0.53, 0.05, 0.95), + ); + return contrast(value, option(options, "contrast", 1.32, 0, 8)); +} + +/** fBm evaluated through a second pair of animated fBm coordinate fields. */ +export function domainWarp(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 2.1, 0.001, 256); + const speed = option(options, "speed", 0.2, -20, 20); + const strength = option(options, "warp", 0.72, 0, 6); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const pz = finite(time) * speed; + const qx = kit._fbmSigned(px + 5.2, py + 1.3, pz, 3, 2, 0.5); + const qy = kit._fbmSigned(px - 2.8, py + 8.1, pz + 3.4, 3, 2, 0.5); + const value = + kit._fbmSigned( + px + qx * strength, + py + qy * strength, + pz + (qx - qy) * 0.22, + optionInt(options, "octaves", 5, 1, 9), + option(options, "lacunarity", 2.02, 1.01, 4), + option(options, "gain", 0.5, 0.05, 0.95), + ) * + 0.5 + + 0.5; + return contrast(value, option(options, "contrast", 1.18, 0, 8)); +} + +/** Curl magnitude of animated gradient noise, useful as turbulent density. */ +export function curl(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 2.2, 0.001, 256); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const pz = finite(time) * option(options, "speed", 0.18, -20, 20); + kit._setCurl( + px, + py, + pz, + option(options, "epsilon", 0.018, 0.0001, 0.2), + ); + const density = + 1 - + Math.exp( + -kit._curlMagnitude * option(options, "strength", 0.72, 0.001, 20), + ); + const modulation = + kit._fbmSigned(px * 0.63, py * 0.63, pz + 4.7, 3, 2, 0.5) * 0.5 + 0.5; + return contrast( + density * 0.74 + modulation * 0.26, + option(options, "contrast", 1.2, 0, 8), + ); +} + +/** Animated ribbons advected along a normalized curl-noise direction. */ +export function flow(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 2.15, 0.001, 256); + const speed = option(options, "speed", 0.24, -20, 20); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const pz = finite(time) * speed; + kit._setCurl(px * 0.72, py * 0.72, pz, 0.02); + const warp = option(options, "warp", 0.65, 0, 6); + const ax = px + kit._curlX * warp; + const ay = py + kit._curlY * warp; + const base = kit._fbmSigned(ax, ay, pz, 4, 2.03, 0.5); + const directional = + ax * kit._curlX * 0.37 + ay * kit._curlY * 0.37 + base * 1.35; + const ribbon = + 0.5 + + Math.sin( + directional * TAU - + pz * option(options, "travel", 1.7, -20, 20), + ) * + 0.5; + const value = ribbon * 0.72 + (base * 0.5 + 0.5) * 0.28; + return contrast(value, option(options, "contrast", 1.28, 0, 8)); +} + +/** Animated Worley feature islands (bright centers, dark gaps). */ +export function worley(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 3.4, 0.001, 256); + kit._setCellular( + finite(x) * frequency, + finite(y) * frequency, + finite(time) * option(options, "speed", 0.52, -20, 20), + option(options, "jitter", 0.9, 0, 1), + option(options, "motion", 0.1, 0, 0.45), + ); + const distance = Math.sqrt(kit._cellF1) / SQRT2; + const value = + 1 - + smoothstep( + option(options, "inner", 0.035, 0, 1), + option(options, "outer", 0.62, 0.001, 2), + distance, + ); + return contrast(value, option(options, "contrast", 1.1, 0, 8)); +} + +/** Voronoi borders whose feature sites ease between deterministic seed states. */ +export function voronoi(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 3.25, 0.001, 256); + kit._setCellular( + finite(x) * frequency, + finite(y) * frequency, + finite(time) * option(options, "speed", 0.43, -20, 20) + + option(options, "phase", 0, -10000, 10000), + option(options, "jitter", 0.92, 0, 1), + option(options, "motion", 0.16, 0, 0.45), + option(options, "seedRate", 0.9, 0, 8), + ); + const edgeDistance = Math.sqrt(kit._cellF2) - Math.sqrt(kit._cellF1); + const width = option(options, "width", 0.085, 0.001, 1); + const value = 1 - smoothstep(width, width * 2.75, edgeDistance); + return contrast(value, option(options, "contrast", 1.32, 0, 8)); +} + +/** Layered sinusoidal plasma with a noise-driven phase field. */ +export function plasma(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 1.45, 0.001, 256); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const phase = + finite(time) * option(options, "speed", 0.62, -20, 20) + + option(options, "phase", 0, -10000, 10000); + const noise = kit._fbmSigned(px * 0.7, py * 0.7, phase * 0.18, 3, 2, 0.5); + const a = Math.sin(px * TAU + phase + noise * 1.6); + const b = Math.sin(py * TAU * 1.13 - phase * 1.17 - noise * 1.35); + const c = Math.sin((px + py) * TAU * 0.61 + phase * 0.71 + noise * 2.1); + const d = Math.sin(Math.hypot(px - 0.5, py - 0.5) * TAU * 2.2 - phase); + return contrast( + 0.5 + (a + b + c + d) * 0.125, + option(options, "contrast", 1.12, 0, 8), + ); +} + +/** Traveling circular waves from multiple moving emitters. */ +export function interference(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 8.5, 0.01, 256); + const phase = finite(time) * option(options, "speed", 2.1, -50, 50); + const px = finite(x); + const py = finite(y); + const sourceA = phase * 0.23 + kit.seed * 1e-7; + const sourceB = -phase * 0.19 + kit.seed * 1.7e-7; + const ax = 0.24 + Math.sin(sourceA) * 0.09; + const ay = 0.35 + Math.cos(sourceA * 0.87) * 0.11; + const bx = 0.76 + Math.cos(sourceB) * 0.1; + const by = 0.64 + Math.sin(sourceB * 1.09) * 0.09; + const da = Math.hypot(px - ax, py - ay); + const db = Math.hypot(px - bx, py - by); + const waveA = Math.sin(da * frequency * TAU - phase); + const waveB = Math.sin(db * frequency * TAU * 1.037 - phase * 1.11); + const beat = Math.sin((da - db) * frequency * TAU * 0.53 + phase * 0.31); + return contrast( + 0.5 + (waveA + waveB) * 0.19 + beat * 0.12, + option(options, "contrast", 1.2, 0, 8), + ); +} + +/** Rotating noisy spiral arms around a configurable center. */ +export function vortex(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const centerX = option(options, "centerX", 0, -1000, 1000); + const centerY = option(options, "centerY", 0, -1000, 1000); + const frequency = option(options, "frequency", 1, 0.001, 256); + const dx = (finite(x) - centerX) * frequency; + const dy = (finite(y) - centerY) * frequency; + const radius = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + const phase = finite(time) * option(options, "speed", 1.4, -50, 50); + const turbulence = kit._fbmSigned( + dx * 2.1 + 4.2, + dy * 2.1 - 3.7, + phase * 0.11, + 4, + 2, + 0.5, + ); + const arms = optionInt(options, "arms", 4, 1, 16); + const turns = option(options, "turns", 3.8, -30, 30); + const spiral = + angle * arms + radius * turns * TAU + turbulence * 2.2 - phase; + const value = 0.5 + Math.sin(spiral) * 0.5; + const limit = option(options, "radius", 1.15, 0.01, 20); + const envelope = 1 - smoothstep(limit * 0.72, limit, radius); + return contrast( + value * (0.58 + envelope * 0.42), + option(options, "contrast", 1.25, 0, 8), + ); +} + +/** Smooth union of deterministic, independently moving metaballs. */ +export function metaballs(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 1, 0.001, 256); + const px = fract(finite(x) * frequency); + const py = fract(finite(y) * frequency); + const phase = finite(time) * option(options, "speed", 0.7, -20, 20); + const count = optionInt(options, "count", 6, 2, 16); + const radius = option(options, "radius", 0.115, 0.005, 0.5); + const radiusSquared = radius * radius; + let influence = 0; + + for (let i = 0; i < count; i += 1) { + const seedX = kit.hash2(i, 17, 41); + const seedY = kit.hash2(i, 79, 113); + const seedP = kit.hash2(i, 151, 197) * TAU; + const cx = fract(seedX + Math.sin(phase * (0.52 + seedY) + seedP) * 0.17); + const cy = fract(seedY + Math.cos(phase * (0.48 + seedX) + seedP) * 0.17); + let dx = Math.abs(px - cx); + let dy = Math.abs(py - cy); + dx = Math.min(dx, 1 - dx); + dy = Math.min(dy, 1 - dy); + const distanceSquared = dx * dx + dy * dy; + influence += radiusSquared / (distanceSquared + radiusSquared); + } + + const threshold = option(options, "threshold", 0.72, 0.05, 8); + return contrast( + smoothstep(threshold * 0.55, threshold * 1.35, influence), + option(options, "contrast", 1.15, 0, 8), + ); +} + +/** Refractive, sharp cellular light bands resembling water caustics. */ +export function caustics(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 4, 0.001, 256); + const phase = finite(time) * option(options, "speed", 0.56, -20, 20); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const warpX = kit._fbmSigned(px * 0.41, py * 0.41, phase * 0.2, 3, 2, 0.5); + const warpY = kit._fbmSigned( + px * 0.41 + 8.7, + py * 0.41 - 2.4, + phase * 0.2, + 3, + 2, + 0.5, + ); + kit._setCellular( + px + warpX * 0.42, + py + warpY * 0.42, + phase, + 0.96, + 0.12, + ); + const delta = Math.sqrt(kit._cellF2) - Math.sqrt(kit._cellF1); + const width = option(options, "width", 0.075, 0.001, 0.5); + const cellularLine = 1 - smoothstep(width, width * 3.1, delta); + const noiseLine = + 1 - + smoothstep( + 0.04, + 0.3, + Math.abs( + kit._signedNoise3(px * 0.72 + warpX, py * 0.72 + warpY, phase * 0.24), + ), + ); + return contrast( + Math.max(cellularLine, noiseLine * 0.68), + option(options, "contrast", 1.45, 0, 8), + ); +} + +/** Noise-warped sedimentary bands with controllable slope and sharpness. */ +export function strata(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 1, 0.001, 256); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const phase = finite(time) * option(options, "speed", 0.12, -20, 20); + const warp = kit._fbmSigned(px * 1.7, py * 1.1, phase, 5, 2, 0.5); + const grain = kit._signedNoise3(px * 6.3, py * 2.2, phase * 0.7); + const bands = option(options, "bands", 8, 0.1, 128); + const slope = option(options, "slope", 0.12, -20, 20); + const position = + (py + + px * slope + + warp * option(options, "warp", 0.16, 0, 5) - + phase * 0.03) * + bands; + let value = 0.5 + Math.sin(position * TAU + grain * 0.35) * 0.5; + value = Math.pow(value, option(options, "sharpness", 1.5, 0.1, 12)); + value = value * 0.86 + (grain * 0.5 + 0.5) * 0.14; + return contrast(value, option(options, "contrast", 1.18, 0, 8)); +} + +/** Rotating radar beam, concentric rings, and deterministic target blips. */ +export function radar(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const dx = finite(x) - option(options, "centerX", 0, -1000, 1000); + const dy = finite(y) - option(options, "centerY", 0, -1000, 1000); + const radius = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + const sweep = + finite(time) * option(options, "speed", 1.15, -50, 50) + + option(options, "phase", 0, -10000, 10000); + let angleDelta = angle - sweep; + angleDelta = Math.atan2(Math.sin(angleDelta), Math.cos(angleDelta)); + const beamWidth = option(options, "beamWidth", 0.18, 0.005, Math.PI); + const beam = Math.exp(-Math.abs(angleDelta) / beamWidth); + const limit = option(options, "radius", 1.05, 0.01, 20); + const envelope = 1 - smoothstep(limit * 0.86, limit, radius); + const rings = option(options, "rings", 5, 1, 64); + const ringPhase = fract((radius / limit) * rings); + const ringDistance = Math.min(ringPhase, 1 - ringPhase); + const ringLine = + (1 - + smoothstep( + option(options, "ringWidth", 0.025, 0.001, 0.49), + 0.12, + ringDistance, + )) * + envelope * + 0.34; + let blip = 0; + const targets = optionInt(options, "targets", 7, 0, 20); + + for (let i = 0; i < targets; i += 1) { + const tx = kit.hash2(i, 31, 97) * 1.72 - 0.86; + const ty = kit.hash2(i, 83, 181) * 1.72 - 0.86; + const bx = finite(x) - tx; + const by = finite(y) - ty; + const spot = Math.exp( + -(bx * bx + by * by) / + option(options, "blipSize", 0.00085, 0.00001, 0.1), + ); + if (spot > blip) blip = spot; + } + + return clamp( + Math.max(beam * envelope * 0.78, ringLine, blip * (0.28 + beam * 0.72)), + ); +} + +/** Twinkling cellular stars connected by faint Voronoi filaments. */ +export function constellation(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 4.2, 0.001, 256); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const phase = finite(time) * option(options, "speed", 1.7, -50, 50); + kit._setCellular(px, py, 0, option(options, "jitter", 0.96, 0, 1), 0); + const nearest = Math.sqrt(kit._cellF1); + const edge = Math.sqrt(kit._cellF2) - nearest; + const starSize = option(options, "starSize", 0.12, 0.005, 1); + const star = 1 - smoothstep(starSize * 0.16, starSize, nearest); + const twinkle = + 0.68 + + 0.32 * + Math.sin(phase + kit._cellHash * INV_255 * TAU + nearest * 8.0); + const lineWidth = option(options, "lineWidth", 0.035, 0.001, 0.5); + const network = + (1 - smoothstep(lineWidth, lineWidth * 3.8, edge)) * + option(options, "lineOpacity", 0.28, 0, 1); + return contrast( + Math.max(star * twinkle, network), + option(options, "contrast", 1.3, 0, 8), + ); +} + +/** Layered, domain-warped water surface with traveling highlights. */ +export function liquid(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 2.3, 0.001, 256); + const phase = finite(time) * option(options, "speed", 0.35, -20, 20); + const px = finite(x) * frequency; + const py = finite(y) * frequency; + const qx = kit._fbmSigned(px * 0.72, py * 0.72, phase, 4, 2, 0.52); + const qy = kit._fbmSigned( + px * 0.72 + 6.2, + py * 0.72 - 1.7, + phase + 2.1, + 4, + 2, + 0.52, + ); + const warp = option(options, "warp", 0.58, 0, 6); + const surface = kit._fbmSigned( + px + qx * warp, + py + qy * warp, + phase * 0.72, + optionInt(options, "octaves", 5, 1, 9), + 2.03, + 0.51, + ); + const wave = + 0.5 + + Math.sin((py + qx * 0.42) * TAU * 1.35 - phase * 2.4) * 0.5; + const highlight = Math.pow( + 1 - Math.abs(kit._signedNoise3(px * 1.8 + qy, py * 1.8, phase) || 0), + 5, + ); + return contrast( + (surface * 0.5 + 0.5) * 0.52 + wave * 0.31 + highlight * 0.17, + option(options, "contrast", 1.18, 0, 8), + ); +} + +/** Repeating branching lightning channels with traveling spark intensity. */ +export function electric(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const frequency = option(options, "frequency", 1.6, 0.001, 256); + const px = fract(finite(x) * frequency) - 0.5; + const py = finite(y) * frequency; + const phase = finite(time) * option(options, "speed", 1.8, -50, 50); + const path = + kit._fbmSigned(py * 0.46, phase * 0.17, phase * 0.11, 6, 2.12, 0.54) * + option(options, "wander", 0.26, 0, 0.48); + const forkNoise = kit._fbmSigned( + py * 0.93 + 8.4, + phase * 0.23, + phase * 0.19, + 4, + 2, + 0.5, + ); + const width = option(options, "width", 0.025, 0.001, 0.3); + const main = Math.exp(-Math.abs(px - path) / width); + const forkOffset = 0.1 + Math.abs(forkNoise) * 0.17; + const forkGate = smoothstep(-0.2, 0.65, forkNoise); + const forkA = + Math.exp(-Math.abs(px - path - forkOffset) / (width * 1.8)) * forkGate; + const forkB = + Math.exp(-Math.abs(px - path + forkOffset) / (width * 1.8)) * + (1 - forkGate); + const pulse = + 0.67 + + 0.33 * + Math.sin( + py * TAU * option(options, "travel", 2.4, -30, 30) - phase * 4.1, + ); + const sparks = kit._ridged(px * 14, py * 8, phase * 0.5, 3, 2.2, 0.46); + return contrast( + Math.max(main, forkA * 0.7, forkB * 0.7) * pulse * (0.72 + sparks * 0.28), + option(options, "contrast", 1.65, 0, 8), + ); +} + +/** Noise-distorted radial rings radiating from a configurable origin. */ +export function ripple(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const centerX = option(options, "centerX", 0, -1000, 1000); + const centerY = option(options, "centerY", 0, -1000, 1000); + const dx = finite(x) - centerX; + const dy = finite(y) - centerY; + const phase = finite(time) * option(options, "speed", 1.5, -50, 50); + const distortion = kit._fbmSigned(dx * 4, dy * 4, phase * 0.12, 4, 2, 0.5); + const radius = + Math.hypot(dx, dy) + + distortion * option(options, "warp", 0.035, 0, 1); + const rings = option(options, "frequency", 11, 0.01, 256); + const wave = 0.5 + Math.sin(radius * rings * TAU - phase * TAU) * 0.5; + const envelope = + 1 - + smoothstep( + option(options, "radius", 1.15, 0.01, 20) * 0.82, + option(options, "radius", 1.15, 0.01, 20), + radius, + ); + return contrast( + Math.pow(wave, option(options, "sharpness", 2.1, 0.1, 16)) * envelope, + option(options, "contrast", 1.2, 0, 8), + ); +} + +/** Folded polar noise producing rotating mandala-like symmetry. */ +export function kaleidoscope(x, y, time = 0, options = EMPTY_OPTIONS, kit) { + kit = ensureKit(kit); + options = options || EMPTY_OPTIONS; + const dx = finite(x) - option(options, "centerX", 0, -1000, 1000); + const dy = finite(y) - option(options, "centerY", 0, -1000, 1000); + const radius = Math.hypot(dx, dy); + const segments = optionInt(options, "segments", 8, 2, 32); + const sector = TAU / segments; + let angle = + Math.atan2(dy, dx) + + finite(time) * option(options, "speed", 0.32, -20, 20); + angle = fract(angle / sector) * sector; + angle = Math.abs(angle - sector * 0.5); + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + const frequency = option(options, "frequency", 7, 0.001, 256); + const noise = kit._fbmSigned( + px * frequency, + py * frequency, + finite(time) * 0.16, + optionInt(options, "octaves", 5, 1, 9), + 2.03, + 0.5, + ); + const rings = + 0.5 + + Math.sin( + radius * TAU * option(options, "rings", 5, 0, 64) + noise * 2.3, + ) * + 0.5; + return contrast( + (noise * 0.5 + 0.5) * 0.58 + rings * 0.42, + option(options, "contrast", 1.24, 0, 8), + ); +} + +/** + * Canonical named samplers. Each function accepts + * `(x, y, time = 0, options = {}, kit = DEFAULT_FIELD_KIT)` and returns 0..1. + */ +export const FIELDS = Object.freeze({ + fbm, + ridged, + "domain-warp": domainWarp, + curl, + flow, + worley, + voronoi, + plasma, + interference, + vortex, + metaballs, + caustics, + strata, + radar, + constellation, + liquid, + electric, + ripple, + kaleidoscope, +}); + +export const FIELD_IDS = Object.freeze(Object.keys(FIELDS)); + +/** Compatibility aliases accepted by `FieldKit.sample`, `get`, and `has`. */ +export const FIELD_ALIASES = Object.freeze({ + noise: "fbm", + turbulence: "fbm", + ridge: "ridged", + domainWarp: "domain-warp", + domain_warp: "domain-warp", + warp: "domain-warp", + "curl-flow": "flow", + curlFlow: "flow", + cellular: "worley", + cells: "voronoi", + cell: "voronoi", + water: "liquid", + lightning: "electric", + waves: "ripple", + mandala: "kaleidoscope", +}); + +function resolveFieldId(id) { + if (typeof id !== "string") return null; + if (Object.prototype.hasOwnProperty.call(FIELDS, id)) return id; + if (Object.prototype.hasOwnProperty.call(FIELD_ALIASES, id)) { + return FIELD_ALIASES[id]; + } + + // Normalize only the uncommon path so canonical per-pixel sampling allocates + // no temporary strings. + const normalized = id.trim().toLowerCase().replace(/[\s_]+/g, "-"); + if (Object.prototype.hasOwnProperty.call(FIELDS, normalized)) { + return normalized; + } + if (Object.prototype.hasOwnProperty.call(FIELD_ALIASES, normalized)) { + return FIELD_ALIASES[normalized]; + } + return null; +} + +/** Shared seed-0 kit for the standalone sampler functions. */ +export const DEFAULT_FIELD_KIT = new FieldKit(0); + +/** Factory form for consumers that prefer functions over constructors. */ +export function createFieldKit(seed = 0) { + return new FieldKit(seed); +} + +export default FieldKit; diff --git a/web/vendor/src/glyphs.js b/web/vendor/src/glyphs.js new file mode 100644 index 0000000..287d406 --- /dev/null +++ b/web/vendor/src/glyphs.js @@ -0,0 +1,1127 @@ +/** + * Analytic glyph samplers for Orby. + * + * Coordinates are normalized to a square from -1 to 1. Every sampler is pure, + * allocation-light, and returns coverage in the 0..1 range. The relatively + * heavy strokes are intentional: the semantic silhouette survives a 16px grid + * before a field, dither, or pixel-switch operator is applied. + * + * Common context values: + * size | resolution raster width used to select an anti-aliasing feather + * reducedMotion freeze decorative motion at a representative phase + * timelinePhase normalized 0..1 position in the recipe's macro cycle + * progress | value normalized progress for progress/generating/transfer + * audioLevel | level normalized listening/speaking energy + * direction "up", "down", "left", "right", or "sync" + * customSampler (x, y, t, context) => coverage + */ + +const TAU = Math.PI * 2; +const HALF_PI = Math.PI * 0.5; +const EMPTY_CONTEXT = Object.freeze({}); + +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); +const saturate = (value) => clamp(value, 0, 1); +const finite = (value, fallback = 0) => + Number.isFinite(Number(value)) ? Number(value) : fallback; +const fract = (value) => value - Math.floor(value); +const positiveAngle = (value) => ((value % TAU) + TAU) % TAU; + +function smoothstep(edge0, edge1, value) { + if (edge0 === edge1) return value < edge0 ? 0 : 1; + const unit = saturate((value - edge0) / (edge1 - edge0)); + return unit * unit * (3 - 2 * unit); +} + +function motionTime(t, context) { + if (!context?.reducedMotion) return finite(t); + return finite(context.reducedPhase, 0.23) * TAU; +} + +function featherFor(context) { + const explicit = finite(context?.feather, -1); + if (explicit >= 0) return clamp(explicit, 0.006, 0.18); + const size = clamp( + finite(context?.resolution ?? context?.size, 24), + 8, + 1024, + ); + // Keep the analytic edge below half a raster cell. The previous feather was + // wide enough to turn small rings and adjacent strokes into soft blobs once + // the 1-bit gate was applied. + return clamp(0.86 / size, 0.008, 0.085); +} + +function coverage(distance, context, featherScale = 1) { + const feather = featherFor(context) * featherScale; + return 1 - smoothstep(-feather, feather, distance); +} + +function sdCircle(x, y, radius) { + return Math.hypot(x, y) - radius; +} + +function sdRoundBox(x, y, halfWidth, halfHeight, radius) { + const corner = Math.min(radius, halfWidth, halfHeight); + const qx = Math.abs(x) - halfWidth + corner; + const qy = Math.abs(y) - halfHeight + corner; + return ( + Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + + Math.min(Math.max(qx, qy), 0) - + corner + ); +} + +function sdSegment(x, y, ax, ay, bx, by, radius) { + const vx = bx - ax; + const vy = by - ay; + const denominator = vx * vx + vy * vy || 1; + const amount = saturate(((x - ax) * vx + (y - ay) * vy) / denominator); + return Math.hypot(x - (ax + vx * amount), y - (ay + vy * amount)) - radius; +} + +function sdArc(x, y, radius, halfWidth, start, span) { + const safeSpan = clamp(span, 0, TAU); + if (safeSpan >= TAU - 1e-5) { + return Math.abs(Math.hypot(x, y) - radius) - halfWidth; + } + + const relative = positiveAngle(Math.atan2(y, x) - start); + if (relative <= safeSpan) { + return Math.abs(Math.hypot(x, y) - radius) - halfWidth; + } + + const end = start + safeSpan; + const startDistance = + Math.hypot(x - Math.cos(start) * radius, y - Math.sin(start) * radius) - + halfWidth; + const endDistance = + Math.hypot(x - Math.cos(end) * radius, y - Math.sin(end) * radius) - + halfWidth; + return Math.min(startDistance, endDistance); +} + +function sdPolygon(x, y, points) { + let inside = false; + let minimumSquared = Infinity; + let previous = points.length - 1; + + for (let current = 0; current < points.length; current += 1) { + const ax = points[previous][0]; + const ay = points[previous][1]; + const bx = points[current][0]; + const by = points[current][1]; + const vx = bx - ax; + const vy = by - ay; + const denominator = vx * vx + vy * vy || 1; + const amount = saturate(((x - ax) * vx + (y - ay) * vy) / denominator); + const dx = x - (ax + vx * amount); + const dy = y - (ay + vy * amount); + minimumSquared = Math.min(minimumSquared, dx * dx + dy * dy); + + if ( + (ay > y) !== (by > y) && + x < ((bx - ax) * (y - ay)) / (by - ay || Number.EPSILON) + ax + ) { + inside = !inside; + } + previous = current; + } + + return Math.sqrt(minimumSquared) * (inside ? -1 : 1); +} + +function hash(index, seed = 0) { + return fract(Math.sin(index * 127.1 + seed * 311.7) * 43758.5453123); +} + +function stableUnit(index, seed = 0) { + let value = (Math.trunc(seed) ^ Math.imul(index + 1, 0x9e3779b1)) >>> 0; + value = Math.imul(value ^ (value >>> 16), 0x21f0aaad); + value = Math.imul(value ^ (value >>> 15), 0x735a2d97); + return ((value ^ (value >>> 15)) >>> 0) / 4294967296; +} + +/** + * A seed-stable set of four irregular arc neighborhoods. The gaps are as + * important as the marks: proximity and good continuation let the eye close + * the circle without turning the boundary into a spinner or a literal rim. + */ +export function gestaltArcSupport( + angle, + context = EMPTY_CONTEXT, + openness = 0.5, +) { + const seed = finite(context?.seed, 0); + const open = clamp(finite(openness, 0.5), 0, 1); + const phase = stableUnit(701, seed) * TAU; + const halfWidthScale = 1.14 - open * 0.34; + let support = 0; + + for (let index = 0; index < 4; index += 1) { + const center = + phase + + (index * TAU) / 4 + + (stableUnit(733 + index * 29, seed) - 0.5) * 0.54; + const halfWidth = + (0.32 + stableUnit(811 + index * 37, seed) * 0.17) * halfWidthScale; + const delta = Math.abs(positiveAngle(angle - center + Math.PI) - Math.PI); + const arc = 1 - smoothstep(halfWidth * 0.7, halfWidth, delta); + support = Math.max(support, arc); + } + + const stableGrain = 0.92 + saturate(finite(context?.random, 0.5)) * 0.08; + return saturate(support * stableGrain); +} + +const TRIANGLE = Object.freeze([ + Object.freeze([0, -0.72]), + Object.freeze([0.69, 0.56]), + Object.freeze([-0.69, 0.56]), +]); + +const DIAMOND = Object.freeze([ + Object.freeze([0, -0.64]), + Object.freeze([0.62, 0]), + Object.freeze([0, 0.64]), + Object.freeze([-0.62, 0]), +]); + +const HOURGLASS_TOP_SAND = Object.freeze([ + Object.freeze([-0.25, -0.34]), + Object.freeze([0.25, -0.34]), + Object.freeze([0, -0.055]), +]); + +const HOURGLASS_BOTTOM_SAND = Object.freeze([ + Object.freeze([0, 0.055]), + Object.freeze([0.25, 0.34]), + Object.freeze([-0.25, 0.34]), +]); + +const STILL_WORKING_CYCLE_SECONDS = 3.2; + +const SPEAKER = Object.freeze([ + Object.freeze([-0.57, -0.2]), + Object.freeze([-0.32, -0.2]), + Object.freeze([0.02, -0.49]), + Object.freeze([0.02, 0.49]), + Object.freeze([-0.32, 0.2]), + Object.freeze([-0.57, 0.2]), +]); + +const STAR = Object.freeze( + Array.from({ length: 10 }, (_, index) => { + const angle = -HALF_PI + (index * Math.PI) / 5; + const radius = index % 2 === 0 ? 0.67 : 0.29; + return Object.freeze([Math.cos(angle) * radius, Math.sin(angle) * radius]); + }), +); + +const TRANSFER_ROTATION = Object.freeze({ + down: 0, + up: Math.PI, + right: -HALF_PI, + left: HALF_PI, +}); + +function sampleIdle(x, y, t = 0, context = EMPTY_CONTEXT) { + const phase = context?.reducedMotion + ? saturate(finite(context.reducedPhase, 0.18)) + : Number.isFinite(Number(context?.timelinePhase)) + ? fract(Number(context.timelinePhase)) + : fract(finite(t) / 14.4); + const breath = Math.sin(phase * TAU * 2 - HALF_PI); + const radius = 0.67 + breath * 0.022; + const radial = Math.hypot(x, y); + const normalizedX = x / radius; + const normalizedY = y / radius; + const normalZ = Math.sqrt( + Math.max(0, 1 - normalizedX * normalizedX - normalizedY * normalizedY), + ); + + const orbit = phase * TAU - 0.72; + let lightX = Math.cos(orbit); + let lightY = -0.28; + let lightZ = Math.sin(orbit) * 0.9; + const lightLength = Math.hypot(lightX, lightY, lightZ) || 1; + lightX /= lightLength; + lightY /= lightLength; + lightZ /= lightLength; + const illumination = + normalizedX * lightX + normalizedY * lightY + normalZ * lightZ; + // A moon terminator is a lighting gradient, not a silhouette edge. Blend a + // broad penumbra with diffuse light so the phase remains crisp at the rim + // while its interior shades through several pixel-density bands. + const rasterFeather = featherFor(context); + const directLight = Math.pow(Math.max(0, illumination), 1.25); + const penumbra = smoothstep( + -0.24 - rasterFeather, + 0.26 + rasterFeather, + illumination, + ); + const lit = saturate(directLight * 0.82 + penumbra * 0.28); + const sphereShade = 0.68 + normalZ * 0.32; + const disk = coverage(sdCircle(x, y, radius), context); + const surface = disk * lit * sphereShade * (1 + breath * 0.035); + + if (context?.orbBoundary === "gestalt") { + const resolution = clamp( + finite(context?.resolution ?? context?.size, 68), + 8, + 1024, + ); + const cell = 2 / resolution; + const boundaryBand = clamp(cell * 3.1, 0.06, 0.2); + const arcSupport = gestaltArcSupport( + Math.atan2(y, x), + context, + context?.gestaltOpenness, + ); + const edgeBand = + smoothstep( + radius - boundaryBand * 1.16, + radius - boundaryBand * 0.42, + radial, + ) * disk; + const surfaceTone = saturate( + lit * sphereShade * (1 + breath * 0.035), + ); + const cueNeed = 1 - smoothstep(0.42, 0.74, surfaceTone); + const impliedLimb = + edgeBand * + arcSupport * + (0.34 + lit * 0.38 + sphereShade * 0.08) * + cueNeed; + return saturate(Math.max(surface, impliedLimb)); + } + + const rimWidth = 0.038 + (breath + 1) * 0.002; + const rim = coverage(Math.abs(radial - radius) - rimWidth, context); + return saturate(Math.max(rim * 0.86, surface)); +} + +function sampleListening(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const level = saturate( + finite(context.audioLevel ?? context.level ?? context.energy, 0.28), + ); + const microphone = sdRoundBox(x, y + 0.09, 0.155, 0.34, 0.15); + const cradle = sdArc(x, y + 0.04, 0.39, 0.068, 0.05, Math.PI - 0.1); + const stem = sdSegment(x, y, 0, 0.43, 0, 0.64, 0.062); + const foot = sdSegment(x, y, -0.2, 0.64, 0.2, 0.64, 0.062); + let result = coverage( + Math.min(microphone, cradle, stem, foot), + context, + ); + + const pulse = fract(time * 0.34); + const rippleRadius = 0.47 + pulse * 0.25; + const ripple = coverage( + sdArc(x, y + 0.03, rippleRadius, 0.038 + level * 0.025, -0.7, 1.4), + context, + ); + const oppositeRipple = coverage( + sdArc( + x, + y + 0.03, + rippleRadius, + 0.038 + level * 0.025, + Math.PI - 0.7, + 1.4, + ), + context, + ); + result = Math.max(result, (ripple + oppositeRipple) * 0.5 * (0.35 + level)); + return saturate(result); +} + +function sampleThinking(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const angle = time * 0.68 - HALF_PI; + const orbitRadius = 0.51; + let shape = sdArc( + x, + y, + orbitRadius, + 0.052, + angle + 0.36, + TAU - 0.98, + ); + for (let index = 0; index < 3; index += 1) { + const nodeAngle = angle + (index * TAU) / 3; + const node = sdCircle( + x - Math.cos(nodeAngle) * orbitRadius, + y - Math.sin(nodeAngle) * orbitRadius, + index === 0 ? 0.115 : 0.09, + ); + shape = Math.min(shape, node); + } + const core = sdCircle(x, y, 0.115); + return coverage(Math.min(shape, core), context); +} + +function sampleThinkingDeep(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const outerAngle = time * 0.24 - HALF_PI; + const middleAngle = -time * 0.37 + 0.3; + const innerAngle = time * 0.52 + 1.1; + const outer = sdArc(x, y, 0.62, 0.064, outerAngle, TAU - 1.02); + const middle = sdArc(x, y, 0.4, 0.061, middleAngle, TAU - 1.16); + const inner = sdArc(x, y, 0.21, 0.058, innerAngle, TAU - 1.32); + const outerBeadAngle = outerAngle + TAU - 1.02; + const middleBeadAngle = middleAngle + TAU - 1.16; + const outerBead = sdCircle( + x - Math.cos(outerBeadAngle) * 0.62, + y - Math.sin(outerBeadAngle) * 0.62, + 0.092, + ); + const middleBead = sdCircle( + x - Math.cos(middleBeadAngle) * 0.4, + y - Math.sin(middleBeadAngle) * 0.4, + 0.078, + ); + const core = sdCircle(x, y, 0.07 + Math.sin(time * 0.8) * 0.012); + return coverage( + Math.min(outer, middle, inner, outerBead, middleBead, core), + context, + ); +} + +function sampleStillWorking(x, y, t = 0, context = EMPTY_CONTEXT) { + const reducedMotion = Boolean(context?.reducedMotion); + const elapsed = Math.max( + 0, + finite(context?.age ?? context?.elapsed, finite(t)), + ); + const cyclePhase = fract(elapsed / STILL_WORKING_CYCLE_SECONDS); + const phase = reducedMotion + ? saturate(finite(context?.reducedPhase, 0.46)) + : cyclePhase >= 1 - 1e-9 + ? 0 + : cyclePhase; + + // Sand drains while the frame is upright, then the complete hourglass makes + // a deliberate half-turn. The end pose is visually identical to the start, + // so the short cycle closes without a snap. + const drain = smoothstep(0.03, 0.48, phase); + const turn = smoothstep(0.5, 0.76, phase); + const rotation = reducedMotion ? 0 : turn * Math.PI; + const cosine = Math.cos(rotation); + const sine = Math.sin(rotation); + const localX = x * cosine + y * sine; + const localY = -x * sine + y * cosine; + + const topRail = sdSegment( + localX, + localY, + -0.32, + -0.43, + 0.32, + -0.43, + 0.06, + ); + const bottomRail = sdSegment( + localX, + localY, + -0.32, + 0.43, + 0.32, + 0.43, + 0.06, + ); + const upperLeft = sdSegment( + localX, + localY, + -0.29, + -0.37, + -0.075, + 0, + 0.055, + ); + const lowerLeft = sdSegment( + localX, + localY, + -0.075, + 0, + -0.29, + 0.37, + 0.055, + ); + const upperRight = sdSegment( + localX, + localY, + 0.29, + -0.37, + 0.075, + 0, + 0.055, + ); + const lowerRight = sdSegment( + localX, + localY, + 0.075, + 0, + 0.29, + 0.37, + 0.055, + ); + const frame = coverage( + Math.min( + topRail, + bottomRail, + upperLeft, + lowerLeft, + upperRight, + lowerRight, + ), + context, + ); + + const topSurface = -0.34 + drain * 0.285; + const bottomSurface = 0.34 - drain * 0.285; + const topSand = coverage( + Math.max( + sdPolygon(localX, localY, HOURGLASS_TOP_SAND), + topSurface - localY, + ), + context, + 0.82, + ); + const bottomSand = coverage( + Math.max( + sdPolygon(localX, localY, HOURGLASS_BOTTOM_SAND), + bottomSurface - localY, + ), + context, + 0.82, + ); + const falling = Math.sin(drain * Math.PI); + const stream = + coverage( + sdSegment(localX, localY, 0, -0.08, 0, 0.17, 0.026), + context, + ) * falling; + const fallingGrain = + coverage( + sdCircle(localX, localY - (-0.03 + drain * 0.25), 0.036), + context, + ) * falling; + + // Two restrained flip guides rotate with the object. They clarify the turn + // without competing with the hourglass at thumbnail scale. + const guideStart = -0.44; + const guideSpan = 0.78; + const guideA = sdArc(localX, localY, 0.67, 0.036, guideStart, guideSpan); + const guideB = sdArc( + localX, + localY, + 0.67, + 0.036, + guideStart + Math.PI, + guideSpan, + ); + const guideEndA = guideStart + guideSpan; + const guideEndB = guideEndA + Math.PI; + const guideDots = Math.min( + sdCircle( + localX - Math.cos(guideEndA) * 0.67, + localY - Math.sin(guideEndA) * 0.67, + 0.065, + ), + sdCircle( + localX - Math.cos(guideEndB) * 0.67, + localY - Math.sin(guideEndB) * 0.67, + 0.065, + ), + ); + const guides = coverage(Math.min(guideA, guideB, guideDots), context) * 0.68; + + return saturate( + Math.max(frame, topSand, bottomSand, stream, fallingGrain, guides), + ); +} + +function sampleLoading(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const segmentCount = clamp( + Math.round(finite(context.segments, 8)), + 6, + 12, + ); + const head = positiveAngle(time * finite(context.rotationSpeed, 1.35)); + let result = 0; + + for (let index = 0; index < segmentCount; index += 1) { + const start = (index / segmentCount) * TAU - HALF_PI; + const span = (TAU / segmentCount) * 0.56; + const segment = coverage(sdArc(x, y, 0.53, 0.09, start, span), context); + const behind = positiveAngle(head - (start + span * 0.5)); + const tail = Math.exp(-behind * 0.68); + result = Math.max(result, segment * (0.56 + tail * 0.44)); + } + return saturate(result); +} + +function sampleProgress(x, y, t = 0, context = EMPTY_CONTEXT) { + const progress = saturate(finite(context.progress ?? context.value, 0.62)); + const track = coverage( + Math.abs(Math.hypot(x, y) - 0.54) - 0.075, + context, + ); + const amount = progress <= 0 ? 0 : Math.max(0.012, TAU * progress); + const fill = + progress <= 0 + ? 0 + : coverage( + sdArc(x, y, 0.54, 0.105, -HALF_PI, amount), + context, + ); + const capAngle = -HALF_PI + amount; + const cap = + progress <= 0 + ? 0 + : coverage( + sdCircle( + x - Math.cos(capAngle) * 0.54, + y - Math.sin(capAngle) * 0.54, + 0.105, + ), + context, + ); + // The inactive track must remain legible after a 1-bit gate at 16–18 px. + let result = Math.max(track * 0.4, fill, cap); + if (progress >= 0.995) { + result = Math.max( + result, + coverage( + Math.min( + sdSegment(x, y, -0.27, 0.01, -0.07, 0.22, 0.085), + sdSegment(x, y, -0.07, 0.22, 0.33, -0.25, 0.085), + ), + context, + ), + ); + } + return saturate(result); +} + +function sampleGenerating(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const progress = saturate( + finite(context.progress ?? context.value, 0.48), + ); + let result = coverage( + Math.max(sdPolygon(x, y, DIAMOND), -sdPolygon(x, y, DIAMOND) - 0.105), + context, + ); + + const cross = Math.min( + sdSegment(x, y, -0.25, 0, 0.25, 0, 0.058), + sdSegment(x, y, 0, -0.25, 0, 0.25, 0.058), + ); + result = Math.max(result, coverage(cross, context)); + + const targetGlyph = context.targetGlyph; + if ( + typeof targetGlyph === "string" && + resolveGlyphId(targetGlyph) !== "generating" + ) { + const targetCoverage = + typeof context.glyphSampler === "function" + ? context.glyphSampler(targetGlyph, x, y, t, context) + : getGlyph(targetGlyph)(x, y, t, context); + result = Math.max(result * (1 - progress * 0.7), targetCoverage * progress); + } + + const seed = finite(context.seed, 0); + for (let index = 0; index < 5; index += 1) { + const phase = fract(time * (0.11 + index * 0.009) + hash(index, seed)); + const angle = hash(index + 19, seed) * TAU + time * 0.19; + const radius = 0.78 - phase * 0.57; + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + const particle = coverage( + sdCircle(x - px, y - py, 0.045 + phase * 0.025), + context, + ); + result = Math.max(result, particle * (0.35 + progress * 0.65)); + } + return saturate(result); +} + +function sampleSearching(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const lensX = x + 0.1; + const lensY = y + 0.11; + const lens = Math.abs(Math.hypot(lensX, lensY) - 0.4) - 0.085; + const handle = sdSegment(x, y, 0.21, 0.2, 0.59, 0.58, 0.095); + const sweepAngle = time * 1.12 - HALF_PI; + const sweep = sdSegment( + lensX, + lensY, + 0, + 0, + Math.cos(sweepAngle) * 0.34, + Math.sin(sweepAngle) * 0.34, + 0.047, + ); + let result = coverage(Math.min(lens, handle), context); + result = Math.max(result, coverage(sweep, context) * 0.72); + + const hitCount = clamp(Math.round(finite(context.hits, 2)), 0, 8); + for (let index = 0; index < hitCount; index += 1) { + const angle = hash(index + 7, finite(context.seed, 0)) * TAU; + const radius = 0.12 + hash(index + 13, finite(context.seed, 0)) * 0.18; + const hit = coverage( + sdCircle( + lensX - Math.cos(angle) * radius, + lensY - Math.sin(angle) * radius, + 0.045, + ), + context, + ); + result = Math.max(result, hit); + } + return saturate(result); +} + +function sampleToolUse(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const steps = Math.max(1, Math.round(finite(context.steps, 12))); + const angle = context.reducedMotion + ? finite(context.reducedPhase, 0.125) * TAU + : Math.floor(time * 1.8 * steps) / steps; + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + const rx = x * cosine + y * sine; + const ry = -x * sine + y * cosine; + const radius = Math.hypot(rx, ry); + const sector = Math.cos(Math.atan2(ry, rx) * 8); + const tooth = smoothstep(0.18, 0.55, sector) * 0.105; + const outer = radius - (0.5 + tooth); + const inner = radius - 0.275; + const gear = Math.max(outer, -inner); + const hub = Math.abs(radius - 0.125) - 0.043; + const spokes = Math.min( + sdRoundBox(rx, ry, 0.38, 0.044, 0.025), + sdRoundBox(rx, ry, 0.044, 0.38, 0.025), + ); + const clippedSpokes = Math.max(spokes, 0.165 - radius, radius - 0.41); + return coverage(Math.min(gear, hub, clippedSpokes), context); +} + +function sampleSpeaking(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const fallbackEnvelope = + 0.34 + + Math.max(0, Math.sin(time * 5.1)) * 0.32 + + Math.max(0, Math.sin(time * 7.7 + 1.3)) * 0.2; + const level = saturate( + finite(context.audioLevel ?? context.level, fallbackEnvelope), + ); + let result = coverage(sdPolygon(x, y, SPEAKER), context); + const first = coverage( + sdArc(x - 0.01, y, 0.36, 0.065, -0.72, 1.44), + context, + ); + const second = coverage( + sdArc(x - 0.01, y, 0.59, 0.065, -0.68, 1.36), + context, + ); + result = Math.max(result, first * (0.42 + level * 0.58)); + result = Math.max(result, second * smoothstep(0.18, 0.78, level)); + return saturate(result); +} + +function sampleAwaitingInput(x, y, t = 0, context = EMPTY_CONTEXT) { + const phase = context?.reducedMotion + ? saturate(finite(context.reducedPhase, 0.44)) + : Number.isFinite(Number(context?.timelinePhase)) + ? fract(Number(context.timelinePhase)) + : fract(finite(t) / 10.8); + const elapsed = phase * 10.8; + const firstBeat = + 1 - smoothstep(0.18, 0.5, Math.abs(elapsed - 0.64)); + const secondBeat = + (1 - smoothstep(0.16, 0.46, Math.abs(elapsed - 1.42))) * 0.76; + const acknowledgement = saturate( + finite(context?.acknowledgement ?? context?.acknowledged, 0), + ); + const invitation = Math.max(firstBeat, secondBeat) * (1 - acknowledgement); + const resolution = clamp( + finite(context?.resolution ?? context?.size, 68), + 8, + 1024, + ); + const smallGrid = saturate((28 - resolution) / 12); + const radius = Math.hypot(x, y); + const angle = Math.atan2(y, x); + const orbit = phase * TAU; + const ringRadius = 0.65 + invitation * 0.014 - acknowledgement * 0.008; + const ring = coverage( + Math.abs(radius - ringRadius) - + (0.027 + smallGrid * 0.01 + invitation * 0.005), + context, + ); + // A low-cost harmonic mask makes seven irregular points wander and twinkle + // around the focus ring without turning the state into a loading spinner. + const fireflyRadius = + 0.755 + x * y * 0.035; + const fireflyBand = + 1 - smoothstep(0.008, 0.034, Math.abs(radius - fireflyRadius)); + let fireflyLobes = Math.max( + 0, + Math.cos( + angle * 7 + Math.sin(orbit) * 0.42 + x * 1.1 - y * 0.65, + ), + ); + fireflyLobes *= fireflyLobes; + fireflyLobes *= fireflyLobes; + fireflyLobes *= fireflyLobes; + fireflyLobes *= fireflyLobes; + const fireflyTwinkle = + 0.82 + (x - y) * 0.06; + const fireflies = saturate( + fireflyBand * + fireflyLobes * + (fireflyTwinkle + invitation * 0.24 + acknowledgement * 0.12), + ); + const questionStroke = 0.055 + smallGrid * 0.008; + const question = Math.min( + sdArc(x + 0.04, y + 0.18, 0.24, questionStroke, -2.72, 3.58), + sdSegment(x, y, 0.117, 0.002, 0.005, 0.145, questionStroke - 0.001), + sdSegment(x, y, 0.005, 0.145, 0.005, 0.235, questionStroke - 0.001), + sdCircle( + x - 0.005, + y - 0.405, + 0.066 + smallGrid * 0.014 + invitation * 0.01, + ), + ); + const prompt = coverage(question, context); + return saturate( + Math.max( + prompt, + ring * + (0.52 + + smallGrid * 0.26 + + invitation * 0.28 + + acknowledgement * 0.14), + fireflies, + ), + ); +} + +function sampleSuccess(x, y, t = 0, context = EMPTY_CONTEXT) { + const age = Math.max(0, finite(context.age ?? context.elapsed, t)); + const tail = sdSegment(x, y, -0.48, 0.02, -0.13, 0.34, 0.105); + const head = sdSegment(x, y, -0.13, 0.34, 0.49, -0.38, 0.105); + let result = coverage(Math.min(tail, head), context); + if (!context.reducedMotion && age < 0.72) { + const haloRadius = 0.5 + smoothstep(0, 0.72, age) * 0.28; + const halo = coverage( + Math.abs(Math.hypot(x, y) - haloRadius) - 0.04, + context, + ); + result = Math.max(result, halo * (1 - smoothstep(0.2, 0.72, age))); + } + return saturate(result); +} + +function sampleWarning(x, y, t = 0, context = EMPTY_CONTEXT) { + const triangleDistance = sdPolygon(x, y, TRIANGLE); + const outline = Math.abs(triangleDistance) - 0.075; + const stem = sdRoundBox(x, y + 0.08, 0.077, 0.245, 0.055); + const dot = sdCircle(x, y - 0.36, 0.087); + return coverage(Math.min(outline, stem, dot), context); +} + +function sampleError(x, y, t = 0, context = EMPTY_CONTEXT) { + const age = Math.max(0, finite(context.age ?? context.elapsed, t)); + const shake = + context.reducedMotion || age > 0.44 + ? 0 + : Math.sin(age * 54) * 0.065 * (1 - age / 0.44); + const first = sdSegment( + x - shake, + y, + -0.43, + -0.43, + 0.43, + 0.43, + 0.105, + ); + const second = sdSegment( + x - shake, + y, + 0.43, + -0.43, + -0.43, + 0.43, + 0.105, + ); + return coverage(Math.min(first, second), context); +} + +function samplePaused(x, y, t = 0, context = EMPTY_CONTEXT) { + const left = sdRoundBox(x + 0.22, y, 0.105, 0.5, 0.055); + const right = sdRoundBox(x - 0.22, y, 0.105, 0.5, 0.055); + return coverage(Math.min(left, right), context); +} + +function sampleCancelled(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const settle = context.reducedMotion + ? 1 + : smoothstep(0, 0.36, Math.max(0, finite(context.age, time))); + const extent = 0.39 + (1 - settle) * 0.12; + const stop = sdRoundBox(x, y, extent, extent, 0.075); + const inset = sdRoundBox(x, y, extent - 0.13, extent - 0.13, 0.04); + const ring = Math.max(stop, -inset); + return coverage(Math.min(ring, sdCircle(x, y, 0.095)), context); +} + +function sampleOffline(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const upper = sdArc(x, y, 0.54, 0.075, -2.92, 2.28); + const lower = sdArc(x, y, 0.54, 0.075, 0.22, 2.28); + const slash = sdSegment(x, y, -0.49, -0.49, 0.49, 0.49, 0.09); + let result = coverage(Math.min(upper, lower, slash), context); + const retry = saturate( + finite(context.retry ?? context.signalStrength, 0.25), + ); + const packetPhase = fract(time * (0.19 + retry * 0.16)); + const packetAngle = -2.92 + packetPhase * 2.28; + const packet = coverage( + sdCircle( + x - Math.cos(packetAngle) * 0.54, + y - Math.sin(packetAngle) * 0.54, + 0.07, + ), + context, + ); + result = Math.max(result, packet * (0.28 + retry * 0.72)); + return saturate(result); +} + +function sampleTransfer(x, y, t = 0, context = EMPTY_CONTEXT) { + const direction = String(context.direction ?? "down").toLowerCase(); + const progress = saturate( + finite(context.progress ?? context.value, 0.54), + ); + if (direction === "sync") { + const time = motionTime(t, context); + const angle = time * 0.85; + const first = sdArc(x, y, 0.5, 0.08, angle, 2.35); + const second = sdArc(x, y, 0.5, 0.08, angle + Math.PI, 2.35); + const firstTipX = Math.cos(angle + 2.35) * 0.5; + const firstTipY = Math.sin(angle + 2.35) * 0.5; + const secondTipX = Math.cos(angle + Math.PI + 2.35) * 0.5; + const secondTipY = Math.sin(angle + Math.PI + 2.35) * 0.5; + const arrowA = sdSegment( + x, + y, + firstTipX, + firstTipY, + firstTipX - 0.17, + firstTipY - 0.03, + 0.07, + ); + const arrowB = sdSegment( + x, + y, + secondTipX, + secondTipY, + secondTipX + 0.17, + secondTipY + 0.03, + 0.07, + ); + return coverage(Math.min(first, second, arrowA, arrowB), context); + } + + const rotation = TRANSFER_ROTATION[direction] ?? TRANSFER_ROTATION.down; + const cosine = Math.cos(rotation); + const sine = Math.sin(rotation); + const rx = x * cosine - y * sine; + const ry = x * sine + y * cosine; + const shaft = sdRoundBox(rx, ry + 0.13, 0.1, 0.43, 0.05); + const leftHead = sdSegment(rx, ry, 0, 0.48, -0.32, 0.14, 0.09); + const rightHead = sdSegment(rx, ry, 0, 0.48, 0.32, 0.14, 0.09); + const fillLine = -0.58 + progress * 1.16; + const fillMask = ry - fillLine; + const arrow = Math.min(shaft, leftHead, rightHead); + const trackCoverage = coverage(arrow, context) * 0.58; + const fillCoverage = coverage(Math.max(arrow, fillMask), context); + return saturate(Math.max(trackCoverage, fillCoverage)); +} + +function sampleHandoff(x, y, t = 0, context = EMPTY_CONTEXT) { + const time = motionTime(t, context); + const accepted = Boolean(context.accepted ?? context.handoffAccepted); + const leftNode = Math.abs(Math.hypot(x + 0.52, y) - 0.16) - 0.057; + const rightNode = Math.abs(Math.hypot(x - 0.52, y) - 0.16) - 0.057; + const path = sdSegment(x, y, -0.3, 0, 0.29, 0, 0.057); + const arrowA = sdSegment(x, y, 0.29, 0, 0.1, -0.15, 0.06); + const arrowB = sdSegment(x, y, 0.29, 0, 0.1, 0.15, 0.06); + let result = coverage( + Math.min(leftNode, rightNode, path, arrowA, arrowB), + context, + ); + + const phase = accepted ? 1 : fract(time * 0.34); + const packetX = -0.29 + phase * 0.58; + const packetY = 0; + const packet = coverage( + sdCircle(x - packetX, y - packetY, accepted ? 0.09 : 0.06), + context, + ); + result = Math.max(result, packet); + return saturate(result); +} + +function sampleCelebration(x, y, t = 0, context = EMPTY_CONTEXT) { + const age = Math.max(0, finite(context.age ?? context.elapsed, t)); + let result = coverage(sdPolygon(x, y, STAR), context); + if (context.reducedMotion) return result; + + const burst = 1 - smoothstep(0.42, 0.84, age); + const seed = finite(context.seed, 0); + for (let index = 0; index < 7; index += 1) { + const angle = hash(index + 31, seed) * TAU; + const speed = 0.47 + hash(index + 47, seed) * 0.36; + const radius = Math.min(age, 0.84) * speed + 0.45; + const particleX = Math.cos(angle) * radius; + const particleY = Math.sin(angle) * radius; + const particle = + index % 2 === 0 + ? sdCircle(x - particleX, y - particleY, 0.04) + : sdRoundBox(x - particleX, y - particleY, 0.038, 0.075, 0.015); + result = Math.max(result, coverage(particle, context) * burst); + } + return saturate(result); +} + +function sampleCustom(x, y, t = 0, context = EMPTY_CONTEXT) { + const customSampler = + context?.customSampler ?? context?.glyphSampler ?? context?.sampler; + if (typeof customSampler === "function" && customSampler !== sampleCustom) { + const sampled = Number(customSampler(x, y, t, context)); + if (Number.isFinite(sampled)) return saturate(sampled); + } + + const diamond = Math.abs(sdPolygon(x, y, DIAMOND)) - 0.085; + const core = sdCircle(x, y, 0.12); + return coverage(Math.min(diamond, core), context); +} + +/** + * Canonical, un-namespaced glyph IDs. Sprite IDs are accepted by sampleGlyph + * through GLYPH_ALIASES so recipes can stay semantically namespaced. + */ +export const GLYPHS = Object.freeze({ + idle: sampleIdle, + listening: sampleListening, + thinking: sampleThinking, + "thinking-deep": sampleThinkingDeep, + "still-working": sampleStillWorking, + loading: sampleLoading, + progress: sampleProgress, + generating: sampleGenerating, + searching: sampleSearching, + "tool-use": sampleToolUse, + speaking: sampleSpeaking, + "awaiting-input": sampleAwaitingInput, + success: sampleSuccess, + warning: sampleWarning, + error: sampleError, + paused: samplePaused, + cancelled: sampleCancelled, + offline: sampleOffline, + transfer: sampleTransfer, + handoff: sampleHandoff, + celebration: sampleCelebration, + custom: sampleCustom, +}); + +export const GLYPH_IDS = Object.freeze(Object.keys(GLYPHS)); + +export const GLYPH_ALIASES = Object.freeze({ + ready: "idle", + listen: "listening", + reasoning: "thinking", + "deep-thinking": "thinking-deep", + working: "still-working", + spinner: "loading", + determinate: "progress", + generate: "generating", + search: "searching", + tool: "tool-use", + voice: "speaking", + prompt: "awaiting-input", + check: "success", + alert: "warning", + failure: "error", + pause: "paused", + cancel: "cancelled", + disconnected: "offline", + sync: "transfer", + star: "celebration", + "ai.idle": "idle", + "ai.listening": "listening", + "ai.thinking": "thinking", + "ai.thinking-deep": "thinking-deep", + "ai.still-working": "still-working", + "ai.loading": "loading", + "ai.progress": "progress", + "ai.generating": "generating", + "ai.searching": "searching", + "ai.tool-use": "tool-use", + "ai.speaking": "speaking", + "ai.awaiting-input": "awaiting-input", + "status.success": "success", + "status.warning": "warning", + "status.error": "error", + "status.paused": "paused", + "status.cancelled": "cancelled", + "status.offline": "offline", + "transfer.active": "transfer", + "workflow.handoff": "handoff", + "status.celebration": "celebration", +}); + +export function resolveGlyphId(id) { + const normalized = String(id ?? "custom").trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(GLYPHS, normalized)) { + return normalized; + } + return GLYPH_ALIASES[normalized] ?? "custom"; +} + +export function getGlyph(id) { + return GLYPHS[resolveGlyphId(id)]; +} + +export function listGlyphs() { + return GLYPH_IDS; +} + +export function sampleGlyph( + id, + x, + y, + t = 0, + context = EMPTY_CONTEXT, +) { + const safeContext = + context && typeof context === "object" ? context : EMPTY_CONTEXT; + const sampler = getGlyph(id); + const sampled = sampler( + finite(x), + finite(y), + finite(t), + safeContext, + ); + return saturate(Number.isFinite(sampled) ? sampled : 0); +} + +export default GLYPHS; diff --git a/web/vendor/src/joan-engine.js b/web/vendor/src/joan-engine.js new file mode 100644 index 0000000..88272c8 --- /dev/null +++ b/web/vendor/src/joan-engine.js @@ -0,0 +1,4309 @@ +import { FieldKit, bayer8, clamp } from "./fields.js"; +import { gestaltArcSupport, sampleGlyph } from "./glyphs.js"; +import { + canvasGridLayout, + normalizePreviewMode, +} from "./preview-layout.js"; +import { SPRITES, getSprite, listSprites } from "./sprites.js"; +import { + validateFieldId, + validatePixelShape, + validatePixelSwitch, + validateRecipe, + validateSpriteId, + validateTransition, + validateEngineOptions, +} from "./config.js"; + +const TAU = Math.PI * 2; +const DEFAULT_PALETTE = Object.freeze({ + background: "#07080b", + off: "#20232b", + ink: "#f1f3f7", + accent: "#8f9dff", + glow: "#6678ff", +}); + +const POINTER_EFFECT_PROFILES = Object.freeze({ + "steer-light": { warp: 0.28, light: 1, surface: 0.35 }, + "steer-light-only": { warp: 0, light: 1, surface: 0 }, + "steer-highlight": { warp: 0.4, light: 0.9, surface: 0.72 }, + "bend-flow": { warp: 1, light: 0.35, surface: 0.6 }, + "bend-flow-and-light": { warp: 1, light: 0.9, surface: 0.58 }, + "bend-path-midpoint": { warp: 0.82, light: 0.45, surface: 0.7 }, + "bias-decorative-beam": { warp: 0.22, light: 0.75, surface: 0.45 }, + "edge-repel": { warp: -0.64, light: 0.4, surface: -0.3 }, + "edge-sparkle": { warp: 0.12, light: 0.9, surface: 0.82 }, + "expand-beacon": { warp: -0.36, light: 0.7, surface: 0.62 }, + "lateral-wind": { warp: 0.88, light: 0.3, surface: 0.44 }, + "lean-highlight": { warp: 0.3, light: 0.85, surface: 0.48 }, + "push-fragments": { warp: -0.72, light: 0.5, surface: 0.38 }, + "reveal-value": { warp: 0, light: 0.4, surface: 1 }, + "ripple-bias": { warp: 0.5, light: 0.5, surface: 0.72 }, + "separate-shells": { warp: -0.42, light: 0.55, surface: 0.5 }, + "tilt-light": { warp: 0.08, light: 1, surface: 0.15 }, + "turn-aperture": { warp: 0.55, light: 0.72, surface: 0.36 }, + "weak-echo": { warp: 0.18, light: 0.35, surface: 0.2 }, +}); + +const PRESS_EFFECT_PROFILES = Object.freeze({ + acknowledge: { surface: 0.4 }, + "bar-squeeze": { surface: -0.38 }, + "contained-ripple": { surface: 0.78 }, + "core-pulse": { surface: 0.9 }, + "damped-vortex": { surface: 0.68 }, + "emit-inbound-ripple": { surface: 0.86 }, + "host-action": { surface: 0.18 }, + "host-retry": { surface: 0.28 }, + "mechanism-compress": { surface: -0.34 }, + "pinch-core": { surface: -0.5 }, + "spring-compress": { surface: 0.62 }, + "surface-ripple": { surface: 0.82 }, +}); + +export const TRANSITIONS = Object.freeze([ + "field-morph", + "seeded-dissolve", + "radial-cascade", + "angular-sweep", + "scanline", + "contour-trace", + "path-draw", + "axis-flip", + "cluster-dissolve", + "neighbor-ignite", + "glitch-bands", + "instant", +]); + +export const PIXEL_SWITCHES = Object.freeze([ + "ordered-dither", + "temporal-blue-noise", + "threshold-hysteresis", + "sdf-wavefront", + "contour-trace", + "curl-advect", + "neighbor-propagation", + "radial-cascade", + "path-draw", + "axis-flip", + "seeded-dissolve", + "field-morph", +]); + +export const ORB_BACKGROUND_MODES = Object.freeze([ + "none", + "solid", + "pixelated", +]); + +export const PIXEL_SHAPES = Object.freeze([ + "disc", + "square", + "diamond", + "capsule", + "line", + "ring", + "cross", + "square-cross", + "square-cross-ring", +]); + +const DEFAULT_OPTIONS = Object.freeze({ + sprite: "ai.idle", + seed: "joan-v5", + gridSize: 68, + speed: 1, + density: 1, + contrast: 1, + fps: 60, + pixelShape: null, + pixelSwitch: null, + transition: null, + palette: null, + nonErrorPalette: null, + orbBoundary: "defined", + orbBackgroundColor: null, + orbBackgroundMode: "none", + previewMode: "fit", + background: true, + offPixels: true, + interactive: true, + autoplay: true, + autoResize: true, + dprMax: 2, + reducedMotion: "system", + quality: "auto", + ariaLive: null, +}); + +const clamp01 = (value) => clamp(value, 0, 1); +const fract = (value) => value - Math.floor(value); +const easeInOut = (value) => { + const t = clamp01(value); + return t * t * (3 - 2 * t); +}; +const smoothRange = (edge0, edge1, value) => + edge0 === edge1 + ? value < edge0 + ? 0 + : 1 + : easeInOut((value - edge0) / (edge1 - edge0)); +const expEase = (current, target, dt, tau) => + current + (target - current) * (1 - Math.exp(-dt / Math.max(0.001, tau))); + +function hashNumber(value) { + let h = 2166136261; + const input = String(value); + for (let index = 0; index < input.length; index += 1) { + h ^= input.charCodeAt(index); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function hash01(value) { + let n = value | 0; + n ^= n >>> 16; + n = Math.imul(n, 0x7feb352d); + n ^= n >>> 15; + n = Math.imul(n, 0x846ca68b); + n ^= n >>> 16; + return (n >>> 0) / 4294967296; +} + +function normalizeTransition(value) { + const id = + typeof value === "string" + ? value + : value?.name || value?.type || value?.enter; + const aliases = { + dissolve: "seeded-dissolve", + radial: "radial-cascade", + bloom: "radial-cascade", + spiral: "angular-sweep", + wave: "scanline", + shutter: "axis-flip", + glitch: "glitch-bands", + "sdf-wavefront": "contour-trace", + "neighbor-propagation": "neighbor-ignite", + }; + return TRANSITIONS.includes(id) ? id : aliases[id] || "field-morph"; +} + +function normalizePixelSwitch(value) { + const id = + typeof value === "string" + ? value + : value?.name || value?.type || value?.mode; + const aliases = { + "cluster-dissolve": "seeded-dissolve", + "neighbor-ignite": "neighbor-propagation", + }; + const normalized = aliases[id] || id; + return PIXEL_SWITCHES.includes(normalized) ? normalized : "ordered-dither"; +} + +function normalizePixelShape(value) { + const aliases = { + circle: "disc", + "rounded-square": "square", + dot: "disc", + }; + const normalized = aliases[value] || value; + return PIXEL_SHAPES.includes(normalized) ? normalized : "disc"; +} + +const COMPOSITE_PIXEL_SHAPES = Object.freeze({ + "square-cross": Object.freeze(["square", "cross"]), + "square-cross-ring": Object.freeze(["square", "cross", "ring"]), +}); + +function resolvePixelShape(shape, luminance) { + const variants = COMPOSITE_PIXEL_SHAPES[shape]; + if (!variants) return shape; + const bin = Math.min(8, Math.floor(clamp01(Number(luminance) || 0) * 8.999)); + if (variants.length === 2) return bin <= 4 ? variants[0] : variants[1]; + if (bin <= 4) return variants[0]; + if (bin <= 6) return variants[1]; + return variants[2]; +} + +function colorChannels(input, fallback) { + const value = typeof input === "string" ? input.trim() : fallback; + const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value); + if (hex) { + const raw = + hex[1].length === 3 + ? hex[1] + .split("") + .map((part) => part + part) + .join("") + : hex[1]; + return [ + Number.parseInt(raw.slice(0, 2), 16), + Number.parseInt(raw.slice(2, 4), 16), + Number.parseInt(raw.slice(4, 6), 16), + ]; + } + const rgb = value?.match?.(/[\d.]+/g); + if (rgb?.length >= 3) return rgb.slice(0, 3).map(Number); + return colorChannels(fallback, "#ffffff"); +} + +function mixColor(from, to, amount, alpha = 1) { + const t = clamp01(amount); + const a = colorChannels(from, "#ffffff"); + const b = colorChannels(to, "#ffffff"); + return `rgba(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round( + a[1] + (b[1] - a[1]) * t, + )},${Math.round(a[2] + (b[2] - a[2]) * t)},${clamp01(alpha).toFixed(3)})`; +} + +function mixGlowColor(from, to, amount) { + const t = clamp01(amount); + const fromTransparent = + String(from).trim().toLowerCase() === "transparent"; + const toTransparent = String(to).trim().toLowerCase() === "transparent"; + if (fromTransparent && toTransparent) return "transparent"; + if (fromTransparent) { + const color = colorChannels(to, "#ffffff"); + return `rgba(${color[0]},${color[1]},${color[2]},${t.toFixed(3)})`; + } + if (toTransparent) { + const color = colorChannels(from, "#ffffff"); + return `rgba(${color[0]},${color[1]},${color[2]},${(1 - t).toFixed(3)})`; + } + return mixColor(from, to, t, 1); +} + +function xmlSafeString(value) { + let output = ""; + for (const character of String(value)) { + const codePoint = character.codePointAt(0); + if ( + codePoint === 0x09 || + codePoint === 0x0a || + codePoint === 0x0d || + (codePoint >= 0x20 && codePoint <= 0xd7ff) || + (codePoint >= 0xe000 && codePoint <= 0xfffd) || + (codePoint >= 0x10000 && codePoint <= 0x10ffff) + ) { + output += character; + } else { + output += "\ufffd"; + } + } + return output; +} + +function escapeXml(value) { + return xmlSafeString(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeXmlAttribute(value) { + return escapeXml(value) + .replaceAll("\t", " ") + .replaceAll("\n", " ") + .replaceAll("\r", " "); +} + +const SVG_COLOR_KEYWORDS = new Set([ + "aqua", + "black", + "blue", + "fuchsia", + "gray", + "green", + "grey", + "lime", + "maroon", + "navy", + "olive", + "orange", + "purple", + "red", + "silver", + "teal", + "transparent", + "white", + "yellow", +]); + +function safeSvgColor(value, fallback) { + const candidate = typeof value === "string" ? value.trim() : ""; + const normalized = candidate.toLowerCase(); + const isHex = /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate); + const functionMatch = + /^(rgb|rgba|hsl|hsla)\(([\d\s.,%+/-]+)\)$/i.exec(candidate); + const isNumericColorFunction = + Boolean(functionMatch) && /\d/.test(functionMatch[2]); + + if ( + isHex || + SVG_COLOR_KEYWORDS.has(normalized) || + isNumericColorFunction + ) { + return candidate; + } + return fallback; +} + +const ORB_BACKGROUND_RUN_BASE = 128; + +function orbBackgroundRowRun(gridSize, radius, row) { + const size = Math.max(1, Math.round(Number(gridSize) || 1)); + const safeRadius = Math.max(0, Number(radius) || 0); + const y = -1 + ((row + 0.5) * 2) / size; + if (Math.abs(y) > safeRadius) return 0; + const halfWidth = Math.sqrt(Math.max(0, safeRadius ** 2 - y ** 2)); + const start = Math.max( + 0, + Math.ceil(((1 - halfWidth) * size) / 2 - 0.5), + ); + const end = Math.min( + size - 1, + Math.floor(((1 + halfWidth) * size) / 2 - 0.5), + ); + if (end < start) return 0; + return start * ORB_BACKGROUND_RUN_BASE + (end - start + 1); +} + +function drawPixelatedOrbBackground( + context, + gridSize, + radius, + offsetX, + offsetY, + cell, +) { + for (let row = 0; row < gridSize; row += 1) { + const run = orbBackgroundRowRun(gridSize, radius, row); + const count = run % ORB_BACKGROUND_RUN_BASE; + if (!count) continue; + const start = (run - count) / ORB_BACKGROUND_RUN_BASE; + context.fillRect( + offsetX + start * cell, + offsetY + row * cell, + count * cell, + cell, + ); + } +} + +function svgPixelatedOrbBackground( + gridSize, + radius, + offset, + cell, + color, +) { + const rows = []; + for (let row = 0; row < gridSize; row += 1) { + const run = orbBackgroundRowRun(gridSize, radius, row); + const count = run % ORB_BACKGROUND_RUN_BASE; + if (!count) continue; + const start = (run - count) / ORB_BACKGROUND_RUN_BASE; + rows.push( + ``, + ); + } + if (!rows.length) return ""; + return `${rows.join("")}`; +} + +function animatedSvgPixelatedOrbBackground( + gridSize, + radiusSamples, + offset, + cell, + color, + keyTimes, + duration, +) { + const rows = []; + for (let row = 0; row < gridSize; row += 1) { + const runs = Array.from(radiusSamples, (radius) => + orbBackgroundRowRun(gridSize, radius, row), + ); + if (!runs.some(Boolean)) continue; + const xValues = runs.map((run) => { + const count = run % ORB_BACKGROUND_RUN_BASE; + const start = count + ? (run - count) / ORB_BACKGROUND_RUN_BASE + : gridSize / 2; + return (offset + start * cell).toFixed(3); + }); + const widthValues = runs.map( + (run) => ((run % ORB_BACKGROUND_RUN_BASE) * cell).toFixed(3), + ); + rows.push( + ``, + ); + } + if (!rows.length) return ""; + return `${rows.join("")}`; +} + +function svgPalette(recipe, override) { + const palette = recipePalette(recipe, override); + return { + background: safeSvgColor( + palette.background, + DEFAULT_PALETTE.background, + ), + off: safeSvgColor(palette.off, DEFAULT_PALETTE.off), + ink: safeSvgColor(palette.ink, DEFAULT_PALETTE.ink), + accent: safeSvgColor(palette.accent, DEFAULT_PALETTE.accent), + glow: safeSvgColor(palette.glow, DEFAULT_PALETTE.glow), + }; +} + +function svgShapeMarkup( + shape, + cx, + cy, + width, + height, + attributes = "", + children = "", + angle = 0, + strokeWidth = null, +) { + const x = Number(cx); + const y = Number(cy); + const w = Number(width); + const h = Number(height); + const attrs = attributes ? ` ${attributes}` : ""; + if (shape === "square") { + return `${children}`; + } + if (shape === "diamond") { + const half = w * 0.52; + return `${children}`; + } + if (shape === "capsule") { + return `${children}`; + } + if (shape === "line") { + return `${children}`; + } + if (shape === "ring") { + const ringStroke = Number.isFinite(Number(strokeWidth)) + ? Number(strokeWidth) + : Math.max(0.12, Math.min(w, h) * 0.14); + return `${children}`; + } + if (shape === "cross") { + return `${children}`; + } + return `${children}`; +} + +function animatedSvgShapeMarkup( + shape, + width, + height, + luminances, + keyTimes, + duration, + angle = 0, + ringStrokeWidths = [], +) { + const initialRingStroke = Number(ringStrokeWidths[0]) || null; + const ringStrokeAnimation = ringStrokeWidths.length + ? `` + : ""; + const variants = COMPOSITE_PIXEL_SHAPES[shape]; + if (!variants) { + return svgShapeMarkup( + shape, + 0, + 0, + width, + height, + "", + shape === "ring" ? ringStrokeAnimation : "", + angle, + initialRingStroke, + ); + } + const resolved = Array.from(luminances, (luminance) => + resolvePixelShape(shape, luminance), + ); + const layers = variants.map((variant) => { + const visibility = resolved.map((active) => (active === variant ? "1" : "0")); + const values = visibility.join(";"); + return `${svgShapeMarkup( + variant, + 0, + 0, + width, + height, + "", + variant === "ring" ? ringStrokeAnimation : "", + angle, + initialRingStroke, + )}`; + }); + return `${layers.join("")}`; +} + +function recipeValue(recipe, key, fallback) { + const direct = recipe?.[key]; + if (direct !== undefined) return direct; + if (recipe?.render?.[key] !== undefined) return recipe.render[key]; + if (recipe?.pixel?.[key] !== undefined) return recipe.pixel[key]; + if (key === "pixelShape" && recipe?.pixel?.shape !== undefined) { + return recipe.pixel.shape; + } + if (key === "pixelScale" && recipe?.pixel?.scale !== undefined) { + return recipe.pixel.scale; + } + return fallback; +} + +function recipePalette(recipe, override) { + const source = recipe?.palette || {}; + return { + ...DEFAULT_PALETTE, + ...source, + off: source.off || source.shadow || DEFAULT_PALETTE.off, + glow: source.glow || source.accent || DEFAULT_PALETTE.glow, + ...(override || {}), + }; +} + +function resolvePaletteOverride(recipe, candidate) { + if (!candidate || typeof candidate !== "object") return null; + const { variants, ...base } = candidate; + const paletteName = recipe?.palette?.name; + const variant = + paletteName && + variants && + typeof variants === "object" && + variants[paletteName] && + typeof variants[paletteName] === "object" + ? variants[paletteName] + : null; + return variant ? { ...variant, ...base } : base; +} + +function paletteOverrideFor(recipe, options) { + const globalPalette = resolvePaletteOverride(recipe, options?.palette); + const nonErrorPalette = + recipe?.id !== "status.error" && + options?.nonErrorPalette && + typeof options.nonErrorPalette === "object" + ? resolvePaletteOverride(recipe, options.nonErrorPalette) + : null; + + if (!globalPalette) return nonErrorPalette; + if (!nonErrorPalette) return globalPalette; + return { ...globalPalette, ...nonErrorPalette }; +} + +function activePalette(recipe, options) { + return recipePalette(recipe, paletteOverrideFor(recipe, options)); +} + +function activeSvgPalette(recipe, options) { + return svgPalette(recipe, paletteOverrideFor(recipe, options)); +} + +function mixPalette(from, to, amount) { + const t = clamp01(amount); + return { + background: mixColor(from.background, to.background, t, 1), + off: mixColor(from.off, to.off, t, 1), + ink: mixColor(from.ink, to.ink, t, 1), + accent: mixColor(from.accent, to.accent, t, 1), + glow: mixGlowColor(from.glow, to.glow, t), + }; +} + +function recipePixelSwitch(recipe, override) { + if (override && typeof override === "object") { + return { ...override, mode: normalizePixelSwitch(override) }; + } + if (override) return { mode: normalizePixelSwitch(override) }; + if (recipe?.pixelSwitch && typeof recipe.pixelSwitch === "object") { + return { + ...recipe.pixelSwitch, + mode: normalizePixelSwitch(recipe.pixelSwitch), + }; + } + return { mode: normalizePixelSwitch(recipe?.pixelSwitch) }; +} + +function recipePixelScale(recipe) { + const scale = clamp( + Number(recipeValue(recipe, "pixelScale", 0.76)), + 0.24, + 1.12, + ); + const gap = clamp(Number(recipe?.pixel?.gap) || 0, 0, 0.72); + return scale * (1 - gap); +} + +function semanticLabel(recipe) { + return ( + recipe?.semantic?.label || + recipe?.labels?.aria || + recipe?.labels?.default || + recipe?.label || + recipe?.id || + "Procedural AI glyph" + ); +} + +function signalPayloadValue(type, payload = {}) { + if (Array.isArray(payload.results)) return payload.results.length; + if (type === "search.results" && Number.isFinite(Number(payload.results))) { + return Number(payload.results); + } + if (type === "handoff.accepted" && payload.accepted !== undefined) { + return payload.accepted ? 1 : 0; + } + if (type === "tool.wait" && payload.waiting !== undefined) { + return payload.waiting ? 1 : 0; + } + const candidates = [ + payload.value, + payload.energy, + payload.count, + payload.depth, + payload.wait, + payload.strength, + ]; + for (const candidate of candidates) { + const value = Number(candidate); + if (Number.isFinite(value)) return value; + } + return 0.7; +} + +function copyPixelBuffer(engine, recipe, recipeSource = engine.recipeSource) { + return { + recipe, + recipeSource, + phase: engine.phase, + stateElapsed: engine.stateElapsed, + values: engine.values.slice(), + velocities: engine.velocities.slice(), + targets: engine.targets.slice(), + luminance: engine.luminance.slice(), + gates: engine.gates.slice(), + dwell: engine.dwell.slice(), + afterglow: engine.afterglow.slice(), + neighborBuffer: engine.neighborBuffer.slice(), + }; +} + +function withFieldOverride(recipe, field) { + return { + ...recipe, + field, + fieldMix: [ + { + field, + weight: 1, + scale: 1, + blend: "replace", + phase: 0, + speed: 1, + contrast: 1, + }, + ], + }; +} + +/** + * Produces a stable 0..1 activation delay for every transition strategy. + * Exported so product teams can preview or synchronize the engine's wavefronts. + */ +export function transitionDelay( + transition, + x, + y, + random = 0.5, + origin = { x: 0, y: 0 }, +) { + const id = normalizeTransition(transition); + const nx = clamp01((x + 1) * 0.5); + const ny = clamp01((y + 1) * 0.5); + const radius = clamp01(Math.hypot(x - origin.x, y - origin.y) / Math.SQRT2); + const angle = fract((Math.atan2(y - origin.y, x - origin.x) + Math.PI) / TAU); + + switch (id) { + case "seeded-dissolve": + return random; + case "radial-cascade": + return clamp01(radius * 0.86 + random * 0.14); + case "angular-sweep": + return clamp01(angle * 0.78 + radius * 0.16 + random * 0.06); + case "scanline": + return clamp01(ny * 0.82 + Math.sin(x * 9) * 0.05 + random * 0.13); + case "contour-trace": + return clamp01(Math.abs(radius - 0.52) * 1.65 + random * 0.08); + case "path-draw": + return clamp01((nx * 0.42 + ny * 0.58) * 0.9 + random * 0.1); + case "axis-flip": + return clamp01((Math.floor(nx * 12) % 2) * 0.18 + ny * 0.72 + random * 0.1); + case "cluster-dissolve": + return clamp01( + fract(Math.sin(Math.floor(x * 5) * 91.7 + Math.floor(y * 5) * 43.1) * 991.31) * + 0.72 + + random * 0.28, + ); + case "neighbor-ignite": + return clamp01(radius * 0.58 + (nx + ny) * 0.16 + random * 0.1); + case "glitch-bands": + return clamp01(fract(Math.floor(ny * 11) * 0.381 + random * 0.29)); + case "instant": + return 0; + case "field-morph": + default: + return clamp01(random * 0.42 + radius * 0.28 + ny * 0.3); + } +} + +/** + * Canvas 2D renderer for deterministic, semantic pixel glyphs. + * + * The pipeline is: + * glyph/SDF → procedural field → semantic envelope → transition wavefront → + * ordered/temporal quantizer → hysteresis/dwell gate → spring → pixel renderer. + */ +export class JoanGlyphEngine extends EventTarget { + static sprites = SPRITES; + + static listSprites() { + return listSprites(); + } + + constructor(canvas, options = {}) { + super(); + if (!canvas?.getContext) { + throw new TypeError("JoanGlyphEngine requires a canvas element."); + } + + this.canvas = canvas; + this.context = canvas.getContext("2d", { + alpha: true, + desynchronized: true, + }); + if (!this.context) throw new Error("Canvas 2D is not available."); + + const normalizedOptions = validateEngineOptions(options, { + strict: true, + coerce: false, + allowUnknown: false, + }); + this.options = { ...DEFAULT_OPTIONS, ...normalizedOptions }; + if (!Object.hasOwn(normalizedOptions, "orbBackgroundMode")) { + this.options.orbBackgroundMode = this.options.orbBackgroundColor + ? "solid" + : "none"; + } + if (Object.hasOwn(options, "paletteOverride")) { + this.options.palette = + options.paletteOverride && typeof options.paletteOverride === "object" + ? { ...options.paletteOverride } + : null; + } + if ( + this.options.nonErrorPalette && + typeof this.options.nonErrorPalette === "object" + ) { + this.options.nonErrorPalette = { ...this.options.nonErrorPalette }; + } + this.options.gridSize = Math.round( + clamp(Number(this.options.gridSize) || 68, 8, 96), + ); + this.options.speed = clamp(Number(this.options.speed) || 1, 0.05, 5); + this.options.density = clamp(Number(this.options.density) || 1, 0.35, 1.6); + this.options.previewMode = normalizePreviewMode(this.options.previewMode); + + this.seed = String(this.options.seed); + this.seedHash = hashNumber(this.seed); + this.fieldKit = new FieldKit(this.seed); + this.customGlyphs = new Map(); + this.glyphSampler = (id, x, y, time, context) => + this.sampleCustomGlyph(id, x, y, time, context); + this.fieldOptionsScratch = {}; + this.recipeMetadataCache = new WeakMap(); + this.semanticEnvelopeCache = new WeakMap(); + this.paintColorCache = { + key: null, + off: null, + source: null, + target: null, + steady: null, + transition: null, + }; + this.pixelSwitchCache = { recipe: null, override: null, value: null }; + this.clock = 0; + this.phase = 0; + this.stateElapsed = 0; + this.progress = clamp01(Number(this.options.progress) || 0); + this.audioLevel = 0; + this.audioTarget = 0; + this.activityEnergy = 0; + this.press = 0; + this.running = false; + this.destroyed = false; + this.visible = true; + this.documentVisible = true; + this.intersectionVisible = true; + this.frameHandle = null; + this.frameTick = null; + this.lastFrameTime = null; + this.lastPaintTime = -Infinity; + this.simulationAccumulator = 0; + this.simulationStep = + 1 / Math.min(60, clamp(Number(this.options.fps) || 60, 1, 120)); + this.maxSimulationSteps = 12; + this.maxCatchUpSeconds = 0.2; + this.qualitySamplePhase = 0; + this.transitionElapsed = Infinity; + this.transitionDuration = 0; + this.transitionName = "instant"; + this.transitionSerial = 0; + this.completedTransitionSerial = 0; + this.pendingSpriteTransition = null; + this.preservedTransition = null; + this.reducedCrossfade = null; + this.reducedFrameHandle = null; + this.configurationBatchDepth = 0; + this.configurationNeedsRender = false; + this.pendingConfigurationDetail = null; + this.impulses = []; + this.signals = new Map(); + this.semanticSignals = new Map(); + this.semanticContext = Object.create(null); + this.signalState = { + accepted: false, + direction: this.options.direction || "down", + hits: 0, + retry: 0, + rotationSpeed: 1, + segments: 8, + signalStrength: 0, + steps: 12, + targetGlyph: this.options.targetGlyph || null, + toolPhase: 0, + }; + this.listeners = []; + this.interactionListeners = []; + this.qualityTier = this.options.quality === "auto" ? "high" : this.options.quality; + this.qualityPressure = 0; + this.qualityRecovery = 0; + this.stats = { + fps: 0, + activePixels: 0, + frameMs: 0, + resolution: 0, + simulationSteps: 0, + sampledPixels: 0, + quality: this.qualityTier, + }; + this.fpsAccumulator = { time: 0, frames: 0 }; + this.pointerSampleScratch = { x: 0, y: 0, speed: 0 }; + this.sampleContextScratch = Object.create(null); + this.sampleToScratch = Object.create(null); + this.sampleFromScratch = Object.create(null); + this.pointer = { + x: 0, + y: 0, + targetX: 0, + targetY: 0, + speed: 0, + inside: false, + down: false, + lastX: 0, + lastY: 0, + lastTime: 0, + }; + + this.reducedMotion = this.resolveReducedMotion(); + this.currentRecipe = this.resolveRecipe(this.options.sprite); + this.recipeSource = this.currentRecipe; + this.fieldOverride = + this.options.fieldMode === "recipe" ? null : this.options.field || null; + if (this.fieldOverride) { + this.currentRecipe = withFieldOverride( + this.currentRecipe, + this.fieldOverride, + ); + } + this.previousRecipe = this.currentRecipe; + this.allocate(this.options.gridSize); + this.bindCanvas(); + this.observe(); + this.resize(true); + this.updateAria(); + this.renderFrame(0, 0, true); + if (this.options.autoplay && !this.reducedMotion) this.play(); + } + + resolveReducedMotion() { + if (this.options.reducedMotion === true) return true; + if (this.options.reducedMotion === false) return false; + return Boolean( + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches, + ); + } + + resolveRecipe(input) { + if (input && typeof input === "object") return validateRecipe(input); + const id = validateSpriteId(input ?? "ai.idle"); + return getSprite(id); + } + + allocate(size) { + const count = size * size; + this.gridSize = size; + this.values = new Float32Array(count); + this.velocities = new Float32Array(count); + this.targets = new Float32Array(count); + this.luminance = new Float32Array(count); + this.gates = new Uint8Array(count); + this.dwell = new Float32Array(count); + this.afterglow = new Float32Array(count); + this.random = new Float32Array(count); + this.xs = new Float32Array(count); + this.ys = new Float32Array(count); + this.neighborBuffer = new Float32Array(count); + this.transitionMixes = new Float32Array(count); + this.transitionMixes.fill(1); + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const index = y * size + x; + this.xs[index] = ((x + 0.5) / size) * 2 - 1; + this.ys[index] = ((y + 0.5) / size) * 2 - 1; + this.random[index] = hash01(index ^ this.seedHash); + this.dwell[index] = this.random[index] * 0.08; + } + } + this.stats.resolution = size; + } + + bind(target, type, handler, options) { + target?.addEventListener?.(type, handler, options); + this.listeners.push(() => target?.removeEventListener?.(type, handler, options)); + } + + bindInteraction(target, type, handler, options) { + target?.addEventListener?.(type, handler, options); + this.interactionListeners.push(() => + target?.removeEventListener?.(type, handler, options), + ); + } + + activate(options = {}) { + const x = clamp(Number(options.x) || 0, -1, 1); + const y = clamp(Number(options.y) || 0, -1, 1); + const source = options.source || "programmatic"; + const pressConfig = this.currentRecipe?.interactions?.press || {}; + + if (pressConfig.enabled !== false) { + this.press = 1; + const pressStrength = clamp(Number(pressConfig.strength ?? 0.07), 0, 1); + this.emitImpulse("press", { + x, + y, + energy: clamp01( + options.energy === undefined + ? 0.42 + pressStrength * 6.8 + : Number(options.energy), + ), + life: clamp( + options.life === undefined + ? Number(pressConfig.durationMs) / 1000 || 0.18 + : Number(options.life), + 0.1, + 4, + ), + }); + } + if (typeof pressConfig.emits === "string" && pressConfig.emits) { + this.signal(pressConfig.emits, { + value: 1, + x, + y, + source, + }); + } + this.emit("activate", { + sprite: this.currentRecipe.id, + x, + y, + source, + key: options.key || null, + }); + this.renderWhenPaused(); + return this; + } + + bindCanvas() { + if (!this.options.interactive) return; + + const move = (event) => { + const rect = this.canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const now = event.timeStamp / 1000 || performance.now() / 1000; + const nextX = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1); + const nextY = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1); + const dt = Math.max(1 / 240, now - (this.pointer.lastTime || now)); + const distance = Math.hypot(nextX - this.pointer.lastX, nextY - this.pointer.lastY); + this.pointer.speed = Math.min( + 2, + this.pointer.speed * 0.5 + (distance / dt) * 0.035, + ); + this.pointer.targetX = nextX; + this.pointer.targetY = nextY; + this.pointer.lastX = nextX; + this.pointer.lastY = nextY; + this.pointer.lastTime = now; + this.pointer.inside = true; + }; + const enter = (event) => { + this.pointer.inside = true; + move(event); + }; + const leave = () => { + this.pointer.inside = false; + this.pointer.down = false; + this.pointer.targetX = 0; + this.pointer.targetY = 0; + }; + const down = (event) => { + move(event); + this.pointer.down = true; + this.activate({ + x: this.pointer.targetX, + y: this.pointer.targetY, + source: "pointer", + }); + this.canvas.setPointerCapture?.(event.pointerId); + }; + const up = (event) => { + this.pointer.down = false; + this.canvas.releasePointerCapture?.(event.pointerId); + }; + const keydown = (event) => { + const isEnter = event.key === "Enter"; + const isSpace = event.key === " " || event.key === "Spacebar"; + if ((!isEnter && !isSpace) || event.repeat) return; + if (isSpace) event.preventDefault?.(); + this.activate({ + source: "keyboard", + key: isEnter ? "Enter" : "Space", + }); + }; + + this.bindInteraction(this.canvas, "pointermove", move, { passive: true }); + this.bindInteraction(this.canvas, "pointerenter", enter, { passive: true }); + this.bindInteraction(this.canvas, "pointerleave", leave, { passive: true }); + this.bindInteraction(this.canvas, "pointerdown", down, { passive: true }); + this.bindInteraction(this.canvas, "pointerup", up, { passive: true }); + this.bindInteraction(this.canvas, "pointercancel", up, { passive: true }); + this.bindInteraction(this.canvas, "keydown", keydown); + } + + observe() { + if (typeof document !== "undefined") { + this.documentVisible = !document.hidden; + this.visible = this.documentVisible && this.intersectionVisible; + } + if (this.options.autoResize && typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(this.canvas); + } + + if (typeof IntersectionObserver !== "undefined") { + this.intersectionObserver = new IntersectionObserver((entries) => { + const wasVisible = this.visible; + this.intersectionVisible = entries[0]?.isIntersecting !== false; + this.visible = this.documentVisible && this.intersectionVisible; + if (this.visible !== wasVisible) { + this.lastFrameTime = null; + this.simulationAccumulator = 0; + if (this.visible) this.scheduleFrame(); + else this.cancelScheduledFrame(); + } + }); + this.intersectionObserver.observe(this.canvas); + } + + if (typeof window !== "undefined") { + const media = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (media) { + this.motionMedia = media; + const update = () => { + if (this.options.reducedMotion !== "system") return; + this.reducedMotion = media.matches; + if (this.reducedMotion) { + this.pause(); + this.renderFrame(this.clock, 0, true); + } else if (this.options.autoplay) { + this.play(); + } + }; + this.bind(media, "change", update); + } + this.bind(document, "visibilitychange", () => { + const wasVisible = this.visible; + this.documentVisible = !document.hidden; + this.visible = this.documentVisible && this.intersectionVisible; + this.lastFrameTime = null; + if (this.visible !== wasVisible) { + this.simulationAccumulator = 0; + if (this.visible) this.scheduleFrame(); + else this.cancelScheduledFrame(); + } + }); + } + } + + resize(force = false) { + const rect = this.canvas.getBoundingClientRect?.() || {}; + const cssWidth = Math.max( + 1, + Number(rect.width || this.canvas.clientWidth || this.canvas.width || 512), + ); + const cssHeight = Math.max( + 1, + Number(rect.height || this.canvas.clientHeight || this.canvas.height || 512), + ); + const dpr = Math.min( + this.options.dprMax, + typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, + ); + const nextWidth = Math.round(cssWidth * dpr); + const nextHeight = Math.round(cssHeight * dpr); + if ( + force || + this.canvas.width !== nextWidth || + this.canvas.height !== nextHeight || + this.width !== cssWidth || + this.height !== cssHeight + ) { + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + this.width = cssWidth; + this.height = cssHeight; + this.dpr = dpr; + this.renderScaleX = nextWidth / cssWidth; + this.renderScaleY = nextHeight / cssHeight; + this.context.setTransform( + this.renderScaleX, + 0, + 0, + this.renderScaleY, + 0, + 0, + ); + this.context.imageSmoothingEnabled = false; + this.paint(); + } + } + + updateAria() { + const label = semanticLabel(this.currentRecipe); + if (this.options.interactive) { + this.canvas.setAttribute?.("role", "button"); + this.canvas.setAttribute?.("tabindex", "0"); + } else { + this.canvas.setAttribute?.("role", "img"); + this.canvas.removeAttribute?.("tabindex"); + } + this.canvas.setAttribute?.("aria-label", `${label} — animated procedural glyph`); + if (this.options.ariaLive) { + const announcement = + this.currentRecipe?.semantic?.announce || + this.currentRecipe?.labels?.live || + "polite"; + if (["polite", "assertive", "off"].includes(announcement)) { + this.options.ariaLive.setAttribute?.("aria-live", announcement); + } + this.options.ariaLive.textContent = label; + } + } + + emit(type, detail) { + if (type === "configchange" && this.configurationBatchDepth > 0) { + this.pendingConfigurationDetail = { + ...(this.pendingConfigurationDetail || {}), + ...(detail || {}), + }; + return; + } + let event; + if (typeof CustomEvent === "function") { + event = new CustomEvent(type, { detail }); + } else { + event = new Event(type); + Object.defineProperty(event, "detail", { value: detail }); + } + this.dispatchEvent(event); + } + + on(type, listener, options) { + this.addEventListener(type, listener, options); + return () => this.removeEventListener(type, listener, options); + } + + isTransitionActive() { + return ( + this.transitionDuration > 0 && + this.transitionElapsed < this.transitionDuration && + this.previousRecipe !== this.currentRecipe + ); + } + + finishTransition(reason = "completed") { + const serial = this.transitionSerial; + if (!serial || this.completedTransitionSerial === serial) { + this.previousRecipe = this.currentRecipe; + this.transitionMixes?.fill?.(1); + return null; + } + const detail = { + from: this.previousRecipe?.id || null, + to: this.currentRecipe?.id || null, + duration: this.transitionDuration, + elapsed: Math.min(this.transitionElapsed, this.transitionDuration), + transition: this.transitionName, + status: reason === "interrupted" ? "interrupted" : "completed", + reason, + serial, + }; + this.transitionElapsed = this.transitionDuration; + this.previousRecipe = this.currentRecipe; + this.transitionMixes?.fill?.(1); + this.completedTransitionSerial = serial; + this.emit("transitioncomplete", detail); + return detail; + } + + whenTransitionComplete(options = {}) { + const signal = options.signal; + if (!this.isTransitionActive()) { + return Promise.resolve({ + from: this.previousRecipe?.id || null, + to: this.currentRecipe?.id || null, + duration: this.transitionDuration, + elapsed: this.transitionElapsed, + transition: this.transitionName, + status: "completed", + reason: "already-settled", + serial: this.transitionSerial, + }); + } + const serial = this.transitionSerial; + return new Promise((resolve, reject) => { + const abort = () => { + cleanup(); + const error = new Error("Transition wait was aborted."); + error.name = "AbortError"; + reject(error); + }; + const complete = (event) => { + if (event.detail?.serial !== serial) return; + cleanup(); + resolve(event.detail); + }; + const cleanup = () => { + this.removeEventListener("transitioncomplete", complete); + signal?.removeEventListener?.("abort", abort); + }; + if (signal?.aborted) { + abort(); + return; + } + this.addEventListener("transitioncomplete", complete); + signal?.addEventListener?.("abort", abort, { once: true }); + }); + } + + transitionTo(sprite, options = {}) { + const target = + typeof sprite === "string" ? validateSpriteId(sprite) : validateRecipe(sprite); + const targetId = typeof target === "string" ? target : target.id; + const signal = options.signal; + return new Promise((resolve, reject) => { + const abort = () => { + cleanup(); + const error = new Error(`Transition to "${targetId}" was aborted.`); + error.name = "AbortError"; + reject(error); + }; + const complete = (event) => { + if (event.detail?.to !== targetId) return; + cleanup(); + resolve(event.detail); + }; + const cleanup = () => { + this.removeEventListener("transitioncomplete", complete); + signal?.removeEventListener?.("abort", abort); + }; + if (signal?.aborted) { + abort(); + return; + } + this.addEventListener("transitioncomplete", complete); + signal?.addEventListener?.("abort", abort, { once: true }); + try { + const { signal: _signal, ...transitionOptions } = options; + this.setSprite(target, transitionOptions); + } catch (error) { + cleanup(); + reject(error); + } + }); + } + + clearSemanticSignals() { + this.semanticSignals.clear(); + this.semanticContext = Object.create(null); + this.sampleContextScratch = Object.create(null); + this.signalState.accepted = false; + this.signalState.hits = 0; + this.signalState.retry = 0; + this.signalState.signalStrength = 0; + this.signalState.steps = 12; + } + + applyRecipeSignal(type, payload, rawValue) { + const declaration = this.currentRecipe?.interactions?.signals?.find?.( + (signal) => signal.name === type, + ); + if (!declaration?.mapsTo) return null; + + let mappedValue; + if (Array.isArray(declaration.values)) { + const candidate = String( + payload.value ?? payload.direction ?? rawValue, + ).toLowerCase(); + mappedValue = declaration.values.includes(candidate) + ? candidate + : declaration.values[0]; + } else { + const [minimum = 0, maximum = 1] = declaration.range || [0, 1]; + mappedValue = clamp(Number(rawValue), Number(minimum), Number(maximum)); + const previous = Number(this.semanticContext[declaration.mapsTo]); + if (declaration.monotonic && Number.isFinite(previous)) { + mappedValue = Math.max(previous, mappedValue); + } + if (declaration.latches && Number.isFinite(previous) && previous > 0) { + mappedValue = Math.max(previous, mappedValue); + } + } + + const entry = { + declaration, + value: mappedValue, + decaySeconds: Math.max(0, Number(declaration.decayMs) || 0) / 1000, + }; + this.semanticSignals.set(declaration.mapsTo, entry); + this.semanticContext[declaration.mapsTo] = mappedValue; + + switch (declaration.mapsTo) { + case "hitCount": + this.signalState.hits = Math.max(0, Math.round(Number(mappedValue) || 0)); + break; + case "accepted": + this.signalState.accepted = Number(mappedValue) > 0; + break; + case "retryEnergy": + this.signalState.retry = clamp01(Number(mappedValue) || 0); + break; + case "signalStrength": + this.signalState.signalStrength = clamp01(Number(mappedValue) || 0); + break; + case "direction": + this.signalState.direction = String(mappedValue); + break; + default: + break; + } + return entry; + } + + restorePixelBuffer(snapshot) { + if (!snapshot || snapshot.values?.length !== this.values.length) return false; + this.values.set(snapshot.values); + this.velocities.set(snapshot.velocities); + this.targets.set(snapshot.targets); + this.luminance.set(snapshot.luminance); + this.gates.set(snapshot.gates); + this.dwell.set(snapshot.dwell); + this.afterglow.set(snapshot.afterglow); + this.neighborBuffer.set(snapshot.neighborBuffer); + this.phase = snapshot.phase; + this.stateElapsed = snapshot.stateElapsed; + return true; + } + + cancelReducedCrossfade() { + if (this.reducedFrameHandle !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(this.reducedFrameHandle); + } + this.reducedFrameHandle = null; + this.reducedCrossfade = null; + } + + startReducedCrossfade(sourceSnapshot) { + if ( + !sourceSnapshot || + this.currentRecipe?.reducedMotion?.transition === "instant" || + typeof requestAnimationFrame !== "function" + ) { + return false; + } + + this.settleGrid(this.clock, { completeTransition: false }); + const targetSnapshot = copyPixelBuffer( + this, + this.currentRecipe, + this.recipeSource, + ); + this.restorePixelBuffer(sourceSnapshot); + this.previousRecipe = sourceSnapshot.recipe || this.previousRecipe; + this.transitionElapsed = 0; + this.transitionMixes.fill(0); + const crossfade = { + source: sourceSnapshot, + target: targetSnapshot, + startedAt: null, + duration: Math.max(0.001, this.transitionDuration), + }; + this.reducedCrossfade = crossfade; + + const tick = (timestamp) => { + if (this.destroyed || this.reducedCrossfade !== crossfade) return; + if (crossfade.startedAt === null) crossfade.startedAt = timestamp; + const progress = easeInOut( + (timestamp - crossfade.startedAt) / 1000 / crossfade.duration, + ); + let activePixels = 0; + for (let index = 0; index < this.values.length; index += 1) { + this.values[index] = + crossfade.source.values[index] + + (crossfade.target.values[index] - crossfade.source.values[index]) * + progress; + this.luminance[index] = + crossfade.source.luminance[index] + + (crossfade.target.luminance[index] - + crossfade.source.luminance[index]) * + progress; + this.transitionMixes[index] = progress; + if (this.values[index] > 0.32) activePixels += 1; + } + this.transitionElapsed = progress * crossfade.duration; + this.stats.activePixels = activePixels; + this.paint(); + + if (progress < 1) { + this.reducedFrameHandle = requestAnimationFrame(tick); + return; + } + this.restorePixelBuffer(crossfade.target); + this.reducedCrossfade = null; + this.reducedFrameHandle = null; + this.transitionElapsed = this.transitionDuration; + this.finishTransition("reduced-motion-crossfade"); + this.paint(); + }; + + this.reducedFrameHandle = requestAnimationFrame(tick); + return true; + } + + resume(options = {}) { + if (this.currentRecipe?.id !== "status.paused") return this; + const pausedRecipe = this.currentRecipe; + const snapshot = this.preservedTransition; + const destination = + options.sprite || + snapshot?.recipeSource || + snapshot?.recipe || + getSprite("ai.idle"); + const exitDuration = + Number(pausedRecipe?.transition?.exitDurationMs) / + 1000 || Number(pausedRecipe?.transition?.durationMs) / 1000 || 0.28; + + this.setSprite(destination, { + duration: options.duration ?? exitDuration, + transition: options.transition || pausedRecipe?.transition?.exit || "axis-flip", + preservePhase: true, + forceTransition: true, + skipPreserveCapture: true, + skipPausedRender: true, + }); + const restored = this.restorePixelBuffer(snapshot); + this.previousRecipe = pausedRecipe; + this.transitionElapsed = 0; + this.preservedTransition = null; + this.paint(); + this.emit("resume", { + from: pausedRecipe.id, + to: this.currentRecipe.id, + restoredBuffer: restored, + source: options.source || "signal", + }); + return this; + } + + setSprite(sprite, options = {}) { + const transitionActive = this.isTransitionActive(); + if ( + transitionActive && + this.currentRecipe?.transition?.interruptible === false && + !options.forceTransition + ) { + this.pendingSpriteTransition = { + sprite, + options: { ...options }, + }; + this.emit("transitionqueued", { + from: this.currentRecipe?.id, + to: typeof sprite === "string" ? sprite : sprite?.id, + }); + return this; + } + if (this.reducedCrossfade) this.cancelReducedCrossfade(); + if (transitionActive) this.finishTransition("interrupted"); + + const resolved = + sprite === this.currentRecipe && this.recipeSource + ? this.recipeSource + : this.resolveRecipe(sprite); + if (!resolved) throw new RangeError(`Unknown sprite: ${sprite}`); + if (options.useRecipeFields) this.fieldOverride = null; + const previousSource = this.recipeSource || this.currentRecipe; + this.recipeSource = resolved; + const next = this.fieldOverride + ? withFieldOverride(resolved, this.fieldOverride) + : resolved; + const previous = this.currentRecipe; + const reducedSourceSnapshot = + this.reducedMotion && previous + ? copyPixelBuffer(this, previous, previousSource) + : null; + const transitionConfig = next.transition || {}; + const preserveBuffer = + options.preserveBuffer ?? transitionConfig.preserveBuffer ?? false; + if (preserveBuffer && previous && !options.skipPreserveCapture) { + this.preservedTransition = copyPixelBuffer( + this, + previous, + previousSource, + ); + } else if (!options.skipPreserveCapture) { + this.preservedTransition = null; + } + this.previousRecipe = previous || next; + this.currentRecipe = next; + this.transitionSerial += 1; + this.stateElapsed = 0; + this.clearSemanticSignals(); + this.transitionElapsed = 0; + const explicitTransition = options.transition || this.options.transition; + const explicitTransitionConfig = + explicitTransition && typeof explicitTransition === "object" + ? explicitTransition + : {}; + this.transitionDuration = this.reducedMotion + ? clamp( + Number(options.duration) || + Number(next?.reducedMotion?.transitionMs) / 1000 || + 0.12, + 0, + 0.2, + ) + : clamp( + Number(options.duration) || + Number(explicitTransitionConfig.duration) || + Number(explicitTransitionConfig.durationMs) / 1000 || + Number(transitionConfig.duration) || + Number(transitionConfig.durationMs) / 1000 || + 0.42, + 0, + 3, + ); + const explicitTransitionName = + explicitTransition && typeof explicitTransition === "object" + ? explicitTransition.name || + explicitTransition.type || + explicitTransition.enter + : explicitTransition; + this.transitionName = normalizeTransition( + (explicitTransitionName + ? validateTransition(explicitTransitionName) + : null) || + transitionConfig.name || + transitionConfig.type || + transitionConfig, + ); + if (options.immediate) { + this.transitionElapsed = this.transitionDuration; + this.transitionName = "instant"; + } + this.transitionMixes.fill( + !options.immediate && + this.transitionDuration > 0 && + this.previousRecipe !== this.currentRecipe + ? 0 + : 1, + ); + const preservePhase = + options.preservePhase ?? + explicitTransitionConfig.preservePhase ?? + transitionConfig.preservePhase ?? + true; + if (!preservePhase) this.phase = 0; + const reducedCrossfadeStarted = + this.reducedMotion && + !options.immediate && + this.transitionDuration > 0 && + this.startReducedCrossfade(reducedSourceSnapshot); + this.updateAria(); + this.emit("spritechange", { + from: previous?.id, + to: next.id, + label: semanticLabel(next), + }); + if (!this.running) { + if (this.configurationBatchDepth > 0) { + this.configurationNeedsRender = true; + } else if ( + reducedCrossfadeStarted || + preserveBuffer || + options.skipPausedRender + ) { + this.paint(); + } else { + this.renderFrame(this.clock, 1 / 60, true); + } + } + if ( + this.configurationBatchDepth === 0 && + !this.isTransitionActive() && + !reducedCrossfadeStarted + ) { + this.finishTransition(options.immediate ? "immediate" : "settled"); + } + return this; + } + + setField(field) { + const canonicalField = validateFieldId(field); + this.fieldOverride = canonicalField; + this.currentRecipe = withFieldOverride( + this.recipeSource || this.currentRecipe, + canonicalField, + ); + this.emit("configchange", { field: canonicalField }); + if (!this.running && this.configurationBatchDepth === 0) { + this.renderFrame(this.clock, 1 / 60, true); + } else if (this.configurationBatchDepth > 0) { + this.configurationNeedsRender = true; + } + return this; + } + + useRecipeFields() { + if (!this.fieldOverride) return this; + this.fieldOverride = null; + this.setSprite(this.recipeSource || this.currentRecipe, { + transition: "field-morph", + preservePhase: true, + useRecipeFields: true, + }); + this.emit("configchange", { field: null, fieldMode: "recipe" }); + return this; + } + + setPixelSwitch(pixelSwitch) { + const requested = + typeof pixelSwitch === "string" + ? pixelSwitch + : pixelSwitch?.name || pixelSwitch?.type || pixelSwitch?.mode; + const mode = validatePixelSwitch(requested); + this.options.pixelSwitch = + pixelSwitch && typeof pixelSwitch === "object" + ? { ...pixelSwitch, mode } + : mode; + this.emit("configchange", { pixelSwitch: this.options.pixelSwitch }); + this.renderWhenPaused(); + return this; + } + + setPixelShape(pixelShape) { + const canonicalShape = validatePixelShape(pixelShape); + this.options.pixelShape = canonicalShape; + this.emit("configchange", { pixelShape: canonicalShape }); + this.renderWhenPaused(); + return this; + } + + setResolution(size) { + const next = Math.round(clamp(Number(size) || this.gridSize, 8, 96)); + if (next !== this.gridSize) { + this.options.gridSize = next; + this.allocate(next); + this.emit("configchange", { gridSize: next }); + if (this.configurationBatchDepth > 0) { + this.configurationNeedsRender = true; + } else { + this.renderFrame(this.clock, 1 / 60, true); + } + } + return this; + } + + setSeed(seed) { + this.seed = String(seed || "joan-v5"); + this.seedHash = hashNumber(this.seed); + this.fieldKit.reseed?.(this.seed); + for (let index = 0; index < this.random.length; index += 1) { + this.random[index] = hash01(index ^ this.seedHash); + } + this.emit("configchange", { seed: this.seed }); + this.renderWhenPaused(); + return this; + } + + setProgress(value) { + this.progress = clamp01(Number(value) || 0); + this.signal("progress", { value: this.progress }); + return this; + } + + setAudioLevel(value) { + this.audioTarget = clamp01(Number(value) || 0); + if (!this.running) this.audioLevel = this.audioTarget; + this.renderWhenPaused(); + return this; + } + + setNonErrorPalette(palette, options = {}) { + this.options.nonErrorPalette = + palette && typeof palette === "object" ? { ...palette } : null; + if (options.render !== false) this.renderWhenPaused(); + return this; + } + + configure(options = {}, configureOptions = {}) { + const strict = configureOptions.strict !== false; + const normalized = validateEngineOptions(options, { + strict, + coerce: !strict, + allowUnknown: false, + }); + const wasInteractive = Boolean(this.options.interactive); + const wasReducedMotion = this.reducedMotion; + this.configurationBatchDepth += 1; + this.configurationNeedsRender = true; + try { + if (normalized.seed !== undefined) this.setSeed(normalized.seed); + if (normalized.gridSize !== undefined) { + this.setResolution(normalized.gridSize); + } + if ( + normalized.fieldMode === "recipe" || + (Object.hasOwn(normalized, "field") && normalized.field === null) + ) { + this.fieldOverride = null; + this.emit("configchange", { field: null, fieldMode: "recipe" }); + } else if (normalized.field !== undefined) { + this.setField(normalized.field); + } + if (Object.hasOwn(normalized, "pixelShape")) { + if (normalized.pixelShape === null) { + this.options.pixelShape = null; + this.emit("configchange", { pixelShape: null }); + } else { + this.setPixelShape(normalized.pixelShape); + } + } + if (Object.hasOwn(normalized, "pixelSwitch")) { + if (normalized.pixelSwitch === null) { + this.options.pixelSwitch = null; + this.emit("configchange", { pixelSwitch: null }); + } else { + this.setPixelSwitch(normalized.pixelSwitch); + } + } + + const directlyAssigned = [ + "ariaLive", + "autoResize", + "autoplay", + "background", + "contrast", + "density", + "direction", + "dprMax", + "fps", + "interactive", + "offPixels", + "orbBoundary", + "orbBackgroundColor", + "orbBackgroundMode", + "previewMode", + "quality", + "reducedMotion", + "speed", + "targetGlyph", + "transition", + ]; + for (const key of directlyAssigned) { + if (Object.hasOwn(normalized, key)) this.options[key] = normalized[key]; + } + if ( + Object.hasOwn(normalized, "orbBackgroundColor") && + !Object.hasOwn(normalized, "orbBackgroundMode") + ) { + this.options.orbBackgroundMode = normalized.orbBackgroundColor + ? this.options.orbBackgroundMode === "pixelated" + ? "pixelated" + : "solid" + : "none"; + } + if (Object.hasOwn(normalized, "reducedMotion")) { + this.reducedMotion = this.resolveReducedMotion(); + } + this.options.previewMode = normalizePreviewMode(this.options.previewMode); + if (Object.hasOwn(normalized, "palette")) { + this.options.palette = normalized.palette + ? { ...normalized.palette } + : null; + } + if (Object.hasOwn(normalized, "paletteOverride")) { + this.options.palette = normalized.paletteOverride + ? { ...normalized.paletteOverride } + : null; + } + if (Object.hasOwn(normalized, "nonErrorPalette")) { + this.options.nonErrorPalette = normalized.nonErrorPalette + ? { ...normalized.nonErrorPalette } + : null; + } + if (Object.hasOwn(normalized, "progress")) { + this.progress = normalized.progress; + } + if (normalized.sprite !== undefined) { + this.setSprite(normalized.sprite, { + transition: normalized.transition || undefined, + preservePhase: true, + useRecipeFields: normalized.fieldMode === "recipe", + }); + } else if (!this.fieldOverride && normalized.fieldMode === "recipe") { + this.currentRecipe = this.recipeSource || this.currentRecipe; + } + + if ( + normalized.interactive !== undefined && + Boolean(this.options.interactive) !== wasInteractive + ) { + for (const remove of this.interactionListeners.splice(0)) remove(); + if (this.options.interactive) this.bindCanvas(); + this.updateAria(); + } + if (normalized.autoResize === false && this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } else if ( + normalized.autoResize === true && + !this.resizeObserver && + typeof ResizeObserver !== "undefined" + ) { + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(this.canvas); + } + if (Object.hasOwn(normalized, "quality")) { + this.qualityTier = + normalized.quality === "auto" ? "high" : normalized.quality; + this.stats.quality = this.qualityTier; + } + if ( + Object.hasOwn(normalized, "quality") || + Object.hasOwn(normalized, "fps") + ) { + this.qualityPressure = 0; + this.qualityRecovery = 0; + this.qualitySamplePhase = 0; + } + if (Object.hasOwn(normalized, "fps")) { + this.simulationStep = + 1 / Math.min(60, clamp(Number(this.options.fps) || 60, 1, 120)); + this.simulationAccumulator = 0; + } + } finally { + this.configurationBatchDepth -= 1; + } + + const detail = { + ...(this.pendingConfigurationDetail || {}), + configured: Object.keys(normalized), + }; + this.pendingConfigurationDetail = null; + if (this.configurationBatchDepth === 0 && this.configurationNeedsRender) { + this.configurationNeedsRender = false; + if (this.running) this.paint(); + else this.renderFrame(this.clock, 1 / 60, true); + } + this.emit("configchange", detail); + this.updateAria(); + if (this.reducedMotion || normalized.autoplay === false) { + this.pause(); + } else if ( + normalized.autoplay === true || + (wasReducedMotion && Object.hasOwn(normalized, "reducedMotion")) + ) { + this.play(); + } + return this; + } + + setOptions(options = {}) { + return this.configure(options, { strict: false }); + } + + signal(type, payload = {}) { + const rawValue = signalPayloadValue(type, payload); + const value = clamp01(rawValue); + const mapped = this.applyRecipeSignal(type, payload, rawValue); + if (type === "audio.level") { + this.audioTarget = value; + if (!this.running) this.audioLevel = value; + } else if (type === "progress") { + this.progress = clamp01(Number(mapped?.value ?? payload.value) || 0); + } else { + this.activityEnergy = Math.max(this.activityEnergy, value); + const life = clamp(Number(payload.life) || 1.2, 0.1, 12); + this.signals.set(type, { age: 0, life, value, payload: { ...payload } }); + if (type === "search.hit") { + this.signalState.hits = Math.min(8, this.signalState.hits + 1); + } else if (type === "search.results") { + this.signalState.hits = Math.max( + 0, + Math.round(Number(mapped?.value ?? rawValue) || 0), + ); + } else if (type === "network.retry") { + this.signalState.retry = value; + } else if (type === "tool.step") { + this.signalState.steps = Math.max( + 1, + Math.round(Number(payload.steps ?? payload.step) || this.signalState.steps + 1), + ); + } else if (type === "handoff.accepted") { + this.signalState.accepted = payload.accepted !== false; + } else if (type === "transfer.direction" || type === "direction") { + this.signalState.direction = String( + payload.direction || payload.value || "down", + ); + } + if (payload.direction) this.signalState.direction = String(payload.direction); + if (payload.targetGlyph) this.signalState.targetGlyph = payload.targetGlyph; + if (payload.segments) { + this.signalState.segments = Math.max(1, Math.round(Number(payload.segments))); + } + if (payload.rotationSpeed) { + this.signalState.rotationSpeed = Number(payload.rotationSpeed) || 1; + } + this.signalState.signalStrength = Math.max( + this.signalState.signalStrength, + value, + ); + this.emitImpulse(type, { + x: Number(payload.x ?? this.pointer.x), + y: Number(payload.y ?? this.pointer.y), + energy: value, + life: Number(payload.life) || 1.2, + }); + } + this.emit("signal", { type, payload }); + if (type === "resume" && value > 0) { + this.resume({ source: payload.source || "signal" }); + return this; + } + this.renderWhenPaused(); + return this; + } + + renderWhenPaused() { + if (this.configurationBatchDepth > 0) { + this.configurationNeedsRender = true; + return; + } + if (!this.running && !this.destroyed) this.renderOnce(this.clock); + } + + emitImpulse(type, options = {}) { + this.impulses.push({ + type, + x: clamp(Number(options.x) || 0, -1, 1), + y: clamp(Number(options.y) || 0, -1, 1), + age: 0, + life: clamp(Number(options.life) || 1, 0.1, 4), + energy: clamp01(Number(options.energy) || 0.7), + }); + if (this.impulses.length > 12) this.impulses.shift(); + } + + registerGlyph(id, source, dimensions = {}) { + if (!id || (!source && source !== 0)) { + throw new TypeError("registerGlyph requires an id and sampler or bitmap."); + } + if (typeof source === "function") { + this.customGlyphs.set(id, source); + return this; + } + + const rows = Array.isArray(source) && Array.isArray(source[0]) ? source : null; + const width = Math.max(1, dimensions.width || rows?.[0]?.length || 1); + const height = Math.max(1, dimensions.height || rows?.length || 1); + const flat = rows ? rows.flat() : Array.from(source); + this.customGlyphs.set(id, (x, y) => { + const px = Math.round(clamp01((x + 1) * 0.5) * (width - 1)); + const py = Math.round(clamp01((y + 1) * 0.5) * (height - 1)); + return clamp01(Number(flat[py * width + px]) || 0); + }); + return this; + } + + async loadGlyphFile(file, options = {}) { + if (typeof document === "undefined") { + throw new Error("Image glyph loading requires a browser."); + } + const id = options.id || "custom"; + const resolution = Math.round(clamp(options.resolution || 48, 8, 128)); + const url = URL.createObjectURL(file); + try { + const image = await new Promise((resolve, reject) => { + const element = new Image(); + element.onload = () => resolve(element); + element.onerror = () => reject(new Error("The glyph image could not be decoded.")); + element.src = url; + }); + const scratch = document.createElement("canvas"); + scratch.width = resolution; + scratch.height = resolution; + const context = scratch.getContext("2d", { willReadFrequently: true }); + const scale = Math.min(resolution / image.width, resolution / image.height) * 0.84; + const width = image.width * scale; + const height = image.height * scale; + context.clearRect(0, 0, resolution, resolution); + context.drawImage( + image, + (resolution - width) / 2, + (resolution - height) / 2, + width, + height, + ); + const pixels = context.getImageData(0, 0, resolution, resolution).data; + const bitmap = new Float32Array(resolution * resolution); + for (let index = 0; index < bitmap.length; index += 1) { + const offset = index * 4; + const luminance = + (pixels[offset] * 0.2126 + + pixels[offset + 1] * 0.7152 + + pixels[offset + 2] * 0.0722) / + 255; + bitmap[index] = (pixels[offset + 3] / 255) * Math.max(0.3, luminance); + } + this.registerGlyph(id, bitmap, { width: resolution, height: resolution }); + const base = getSprite(options.baseSprite || "ai.generating") || this.currentRecipe; + this.setSprite( + { + ...base, + id, + glyph: id, + label: options.label || "Custom glyph", + semantic: { + ...(base.semantic || {}), + label: options.label || "Custom glyph", + category: "custom", + }, + }, + { transition: options.transition || "field-morph" }, + ); + return id; + } finally { + URL.revokeObjectURL(url); + } + } + + sampleCustomGlyph(id, x, y, time, context) { + const sampler = this.customGlyphs.get(id); + return sampler + ? clamp01(Number(sampler(x, y, time, context)) || 0) + : sampleGlyph(id, x, y, time, context); + } + + scheduleFrame() { + if ( + !this.running || + this.destroyed || + !this.visible || + this.frameHandle !== null || + typeof requestAnimationFrame !== "function" + ) { + return; + } + this.frameHandle = requestAnimationFrame(this.frameTick); + } + + cancelScheduledFrame() { + if ( + this.frameHandle !== null && + typeof cancelAnimationFrame === "function" + ) { + cancelAnimationFrame(this.frameHandle); + } + this.frameHandle = null; + } + + play() { + if (this.running || this.destroyed || this.reducedMotion) return this; + this.running = true; + this.lastFrameTime = null; + if (!this.frameTick) { + this.frameTick = (timestamp) => { + this.frameHandle = null; + if (!this.running || this.destroyed || !this.visible) return; + const now = timestamp / 1000; + if (this.lastFrameTime === null) { + this.lastFrameTime = now; + this.scheduleFrame(); + return; + } + const minInterval = 1 / clamp(Number(this.options.fps) || 60, 1, 120); + const elapsed = now - this.lastPaintTime; + if (elapsed + 0.002 >= minInterval) { + const dt = Math.max(0, now - this.lastFrameTime); + this.lastFrameTime = now; + this.lastPaintTime = now; + this.renderFrame(now, dt); + } + this.scheduleFrame(); + }; + } + this.scheduleFrame(); + this.emit("play", { sprite: this.currentRecipe.id }); + return this; + } + + pause() { + if (!this.running) return this; + this.running = false; + this.cancelScheduledFrame(); + this.emit("pause", { sprite: this.currentRecipe.id }); + return this; + } + + toggle() { + return this.running ? this.pause() : this.play(); + } + + updateDynamics(dt) { + const speed = this.options.speed; + this.clock += dt; + this.phase += dt * speed; + const toolHold = clamp01(Number(this.semanticContext.stepHold) || 0); + this.signalState.toolPhase += dt * speed * (1 - toolHold); + this.stateElapsed += dt; + this.transitionElapsed += dt; + this.pointer.x = expEase( + this.pointer.x, + this.pointer.inside ? this.pointer.targetX : 0, + dt, + 0.13, + ); + this.pointer.y = expEase( + this.pointer.y, + this.pointer.inside ? this.pointer.targetY : 0, + dt, + 0.13, + ); + this.pointer.speed = expEase(this.pointer.speed, 0, dt, 0.2); + const pressConfig = this.currentRecipe?.interactions?.press || {}; + const pressStrength = clamp(Number(pressConfig.strength ?? 0.07), 0, 1); + const pressDuration = clamp( + Number(pressConfig.durationMs) / 1000 || 0.18, + 0.08, + 1.2, + ); + this.press = expEase( + this.press, + this.pointer.down ? clamp(0.2 + pressStrength * 3.6, 0.2, 1) : 0, + dt, + pressDuration * 0.72, + ); + this.audioLevel = expEase(this.audioLevel, this.audioTarget, dt, 0.09); + this.audioTarget = expEase(this.audioTarget, 0, dt, 0.28); + this.activityEnergy = expEase(this.activityEnergy, 0, dt, 0.5); + this.signalState.retry = expEase(this.signalState.retry, 0, dt, 0.75); + this.signalState.signalStrength = expEase( + this.signalState.signalStrength, + 0, + dt, + 0.5, + ); + + if (this.impulses.length) { + for (const impulse of this.impulses) impulse.age += dt; + this.impulses = this.impulses.filter((impulse) => impulse.age < impulse.life); + } + for (const [name, signal] of this.signals) { + signal.age += dt; + if (signal.age >= signal.life) this.signals.delete(name); + } + for (const [mapsTo, semanticSignal] of this.semanticSignals) { + if ( + semanticSignal.decaySeconds > 0 && + typeof semanticSignal.value === "number" + ) { + semanticSignal.value = expEase( + semanticSignal.value, + 0, + dt, + semanticSignal.decaySeconds, + ); + if (Math.abs(semanticSignal.value) < 0.001) semanticSignal.value = 0; + this.semanticContext[mapsTo] = semanticSignal.value; + } + } + + if ( + this.previousRecipe !== this.currentRecipe && + this.completedTransitionSerial !== this.transitionSerial && + this.transitionElapsed >= this.transitionDuration + ) { + this.finishTransition("completed"); + } + + if ( + this.pendingSpriteTransition && + this.transitionElapsed >= this.transitionDuration + ) { + const pending = this.pendingSpriteTransition; + this.pendingSpriteTransition = null; + this.setSprite(pending.sprite, { + ...pending.options, + forceTransition: true, + }); + } + + const timeline = this.currentRecipe?.timeline; + const timelineEnd = + (Number(timeline?.durationMs) + Number(timeline?.holdMs || 0)) / 1000; + if ( + timeline?.next && + Number.isFinite(timelineEnd) && + timelineEnd > 0 && + this.stateElapsed >= timelineEnd + ) { + const completed = this.currentRecipe; + this.setSprite(timeline.next, { + duration: + Number(completed?.transition?.exitDurationMs) / 1000 || undefined, + transition: completed?.transition?.exit || "field-morph", + forceTransition: true, + }); + this.emit("timelinecomplete", { + from: completed.id, + to: this.currentRecipe.id, + }); + } + } + + recipeMetadata(recipe) { + const key = recipe && typeof recipe === "object" ? recipe : this.currentRecipe; + const cached = this.recipeMetadataCache.get(key); + if (cached) return cached; + const meaning = String(key?.semantic?.meaning || "").toLowerCase(); + const category = String(key?.semantic?.category || "").toLowerCase(); + const metadata = { + meaning, + category, + toolAction: /executing a tool|tool action/.test(meaning), + deliberateReasoning: /deliberate.*reasoning/.test(meaning), + voiceOutput: + /voice output|speaking/.test(meaning) || + Number(key?.composition?.voice) > 0, + listening: /capturing voice|listening/.test(meaning), + presence: + category === "presence" || + /calm.*presence|ready and available/.test(meaning), + ambientReasoning: category === "ambient" && /reasoning/.test(meaning), + }; + this.recipeMetadataCache.set(key, metadata); + return metadata; + } + + resolvedPixelSwitch(recipe = this.currentRecipe) { + const override = this.options.pixelSwitch; + if ( + this.pixelSwitchCache.recipe !== recipe || + this.pixelSwitchCache.override !== override + ) { + this.pixelSwitchCache.recipe = recipe; + this.pixelSwitchCache.override = override; + this.pixelSwitchCache.value = recipePixelSwitch(recipe, override); + } + return this.pixelSwitchCache.value; + } + + semanticEnvelope(recipe, time, metadata = this.recipeMetadata(recipe)) { + const cacheKey = recipe && typeof recipe === "object" ? recipe : this.currentRecipe; + let cached = this.semanticEnvelopeCache.get(cacheKey); + if (cached?.time === time && cached.audioLevel === this.audioLevel) { + return cached.value; + } + const gentleBreath = + 0.6 * Math.sin(time * 0.9) + 0.4 * Math.sin(time * 0.333 + 1.3); + const syllable = + 0.5 + 0.5 * Math.sin(time * 9) * Math.sin(time * 3.7 + 1.1); + const gate = Math.max( + 0, + Math.sin(time * 2.3) + 0.35 * Math.sin(time * 5.9), + ); + const voice = (0.25 + 0.75 * syllable) * Math.min(1, gate); + let value; + if (metadata.voiceOutput) { + value = clamp01(this.audioLevel || voice * 0.8); + } else if (metadata.listening) { + value = clamp01(this.audioLevel * 0.8 + 0.2); + } else if (metadata.presence) { + value = 0.5 + gentleBreath * 0.14; + } else if ( + metadata.deliberateReasoning || + metadata.ambientReasoning + ) { + const cycle = fract(time * 0.22); + value = cycle < 0.45 + ? 0.5 - 0.5 * Math.cos(Math.PI * (cycle / 0.45)) + : 0.5 + 0.5 * Math.cos(Math.PI * ((cycle - 0.45) / 0.55)); + } else { + value = 0.5 + gentleBreath * 0.08; + } + if (!cached) { + cached = { time, value, audioLevel: this.audioLevel }; + this.semanticEnvelopeCache.set(cacheKey, cached); + } else { + cached.time = time; + cached.value = value; + cached.audioLevel = this.audioLevel; + } + return value; + } + + orbVisualRadius(recipe, time = this.clock) { + const composition = recipe?.composition || {}; + if (composition.gestaltBoundary !== true) return 0; + + if (composition.mode === "field-orb") { + const baseRadius = clamp(Number(composition.radius) || 0.86, 0.45, 0.98); + const breathe = clamp(Number(composition.breathe) || 0, 0, 0.22); + const envelope = this.semanticEnvelope( + recipe, + time, + this.recipeMetadata(recipe), + ); + return baseRadius * (1 + (envelope - 0.5) * breathe * 0.7); + } + + const cycleMs = + Number(recipe?.timeline?.macroCycleMs) || + Number(recipe?.timeline?.durationMs) || + 14400; + const phase = this.reducedMotion + ? clamp01(Number(recipe?.reducedMotion?.phase) || 0.18) + : fract(time / Math.max(0.001, cycleMs / 1000)); + const breath = Math.sin(phase * TAU * 2 - Math.PI / 2); + const baseRadius = clamp(Number(composition.radius) || 0.67, 0.45, 0.98); + return baseRadius + breath * 0.022; + } + + orbBackgroundRadius(recipe, time = this.clock) { + const radius = this.orbVisualRadius(recipe, time); + if (radius <= 0) return 0; + const composition = recipe?.composition || {}; + const normalizedCell = 2 / Math.max(1, this.gridSize); + const halfPixelDiagonal = + normalizedCell * recipePixelScale(recipe) * (Math.SQRT2 / 2); + const clearance = normalizedCell * 0.25; + const motionClearance = + composition.mode === "field-orb" + ? clamp(Number(composition.radius) || 0.86, 0.45, 0.98) * + clamp(Number(composition.breathe) || 0, 0, 0.22) * + 0.7 + : recipe?.glyph === "idle" + ? 0.044 + : 0; + return Math.min( + 1.1, + radius + motionClearance + halfPixelDiagonal + clearance, + ); + } + + impulseAt(x, y) { + let energy = 0; + for (const impulse of this.impulses) { + const distance = Math.hypot(x - impulse.x, y - impulse.y); + const life = 1 - impulse.age / impulse.life; + const wave = Math.exp( + -Math.abs(distance - impulse.age * (0.7 + impulse.energy * 0.5)) * 18, + ); + energy += wave * life * impulse.energy; + } + return clamp01(energy); + } + + recipeSample(recipe, x, y, time, index, result = null) { + const shellEnergy = clamp01(Number(this.semanticContext.shellEnergy) || 0); + const metadata = this.recipeMetadata(recipe); + const baseTime = metadata.toolAction ? this.signalState.toolPhase : time; + const semanticSpeed = + metadata.deliberateReasoning + ? 1 + shellEnergy * 0.55 + : 1; + const recipeTime = + baseTime * + clamp(Number(recipe?.speed) || 1, 0.02, 8) * + semanticSpeed; + const pointerConfig = recipe?.interactions?.pointer; + const pointerEnabled = + this.options.interactive && + this.gridSize >= Number(pointerConfig?.minSize ?? 20) && + !this.reducedMotion && + recipe?.interactions !== false && + pointerConfig?.enabled !== false; + const pointerStrength = clamp(Number(pointerConfig?.strength ?? 0.16), 0, 1); + const pointerEffect = pointerConfig?.effect || "steer-light"; + const pointerProfile = POINTER_EFFECT_PROFILES[pointerEffect] || { + warp: 0.35, + light: 0.5, + surface: 0.45, + }; + const pointer = this.pointerSampleScratch; + pointer.x = pointerEnabled ? this.pointer.x : 0; + pointer.y = pointerEnabled ? this.pointer.y : 0; + pointer.speed = pointerEnabled ? this.pointer.speed : 0; + const envelope = this.semanticEnvelope(recipe, time, metadata); + const distanceToPointer = Math.hypot(x - pointer.x, y - pointer.y); + const pointerInfluence = + pointerEnabled && this.pointer.inside + ? Math.max(0, 1 - distanceToPointer / 1.15) + : 0; + const warp = + pointerInfluence * + pointerStrength * + (0.075 + pointer.speed * 0.15) * + pointerProfile.warp; + const sampleX = x - (x - pointer.x) * warp; + const sampleY = y - (y - pointer.y) * warp; + const impulse = this.impulseAt(x, y); + const context = this.sampleContextScratch; + context.accepted = this.signalState.accepted; + context.age = this.stateElapsed; + context.audioLevel = this.audioLevel; + context.direction = this.signalState.direction; + context.motionDirection = recipe?.pixelSwitch?.direction || "field"; + context.elapsed = this.stateElapsed; + context.energy = clamp01(this.activityEnergy + impulse); + context.envelope = envelope; + context.handoffAccepted = this.signalState.accepted; + context.hits = this.signalState.hits; + context.glyphSampler = this.glyphSampler; + context.pointer = pointer; + context.pointerEffect = pointerEffect; + context.pointerInfluence = pointerInfluence; + context.press = this.press; + context.pressEffect = + recipe?.interactions?.press?.effect || "spring-compress"; + context.progress = this.progress; + context.random = this.random[index]; + context.retry = this.signalState.retry; + context.rotationSpeed = this.signalState.rotationSpeed; + context.resolution = this.gridSize; + context.segments = this.signalState.segments; + context.size = this.gridSize; + context.seed = this.seedHash; + context.signalStrength = this.signalState.signalStrength; + context.signals = this.signals; + context.steps = this.signalState.steps; + context.targetGlyph = this.signalState.targetGlyph; + context.reducedMotion = this.reducedMotion; + context.reducedPhase = recipe?.reducedMotion?.phase; + const timelineCycleMs = + Number(recipe?.timeline?.macroCycleMs) || + Number(recipe?.timeline?.durationMs) || + 1000; + context.timelinePhase = this.reducedMotion + ? clamp01(Number(recipe?.reducedMotion?.phase) || 0.25) + : fract(time / Math.max(0.001, timelineCycleMs / 1000)); + Object.assign(context, this.semanticContext); + const composition = recipe?.composition || {}; + const compositionMode = composition.mode || "glyph"; + const usesGestaltBoundary = + composition.gestaltBoundary === true && + this.options.orbBoundary === "gestalt"; + context.orbBoundary = usesGestaltBoundary ? "gestalt" : "defined"; + const authoredGestaltOpenness = Number(composition.gestaltOpenness); + context.gestaltOpenness = clamp( + Number.isFinite(authoredGestaltOpenness) + ? authoredGestaltOpenness + : 0.5, + 0, + 1, + ); + const radius = Math.hypot(sampleX, sampleY); + const baseOrbRadius = clamp(Number(composition.radius) || 0.86, 0.45, 0.98); + const breathe = clamp(Number(composition.breathe) || 0, 0, 0.22); + const orbRadius = + baseOrbRadius * (1 + (envelope - 0.5) * breathe * 0.7); + const orbEdge = clamp(1.15 / this.gridSize, 0.022, 0.095); + const orbMask = + 1 - smoothRange(orbRadius - orbEdge, orbRadius + orbEdge, radius); + const glyphId = recipe?.glyph || "idle"; + const sampledGlyph = + compositionMode === "field-orb" + ? orbMask + : this.sampleCustomGlyph( + glyphId, + sampleX, + sampleY, + recipeTime, + context, + ); + const glyph = clamp01(sampledGlyph); + const layers = + Array.isArray(recipe?.fieldMix) && recipe.fieldMix.length + ? recipe.fieldMix + : [ + { + field: + recipe?.field || + recipe?.fields?.[0]?.id || + recipe?.fields?.[0]?.type || + "fbm", + weight: 1, + scale: 1, + speed: 1, + phase: 0, + contrast: 1, + blend: "replace", + }, + ]; + let field = 0.5; + let accumulatedWeight = 0; + for (let layerIndex = 0; layerIndex < layers.length; layerIndex += 1) { + const layer = layers[layerIndex]; + const weight = clamp(Number(layer.weight) || 0, 0, 1); + const scale = clamp(Number(layer.scale) || 1, 0.05, 24); + const layerTime = recipeTime; + const fieldOptions = Object.assign(this.fieldOptionsScratch, context, layer); + let sampled = this.fieldKit.sample( + layer.field || layer.id || layer.type || recipe?.field || "fbm", + sampleX * scale, + sampleY * scale, + layerTime, + fieldOptions, + ); + sampled = clamp01(sampled); + const blend = layer.blend || "add"; + if (accumulatedWeight === 0) { + // The primary layer establishes the full 0..1 range. Weight describes + // how much later layers may influence it, not how much it is dimmed. + field = sampled; + } else if (blend === "replace") { + field = field * (1 - weight) + sampled * weight; + } else if (blend === "screen") { + const screened = 1 - (1 - field) * (1 - sampled); + field = field * (1 - weight) + screened * weight; + } else if (blend === "multiply") { + const multiplied = field * sampled; + field = field * (1 - weight) + multiplied * weight; + } else if (blend === "max" || blend === "lighten") { + field = field * (1 - weight) + Math.max(field, sampled) * weight; + } else if (blend === "soft-light") { + const softened = + sampled < 0.5 + ? field - (1 - 2 * sampled) * field * (1 - field) + : field + + (2 * sampled - 1) * + (Math.sqrt(Math.max(0, field)) - field); + field = field * (1 - weight) + softened * weight; + } else { + // Add detail around the midpoint so an additive layer creates texture + // without washing the entire stack toward white. + field += (sampled - 0.5) * weight * 1.35; + } + accumulatedWeight += weight; + } + const fieldContrast = clamp( + Number(composition.fieldContrast) || + Number(recipe?.fieldContrast) || + 1.16, + 0.5, + 3, + ); + field = clamp01((field - 0.5) * fieldContrast + 0.5); + const radialWindow = clamp01((1.12 - radius) / 0.22); + const ambientMix = clamp( + Number( + recipe?.ambientMix ?? + recipe?.fields?.[0]?.mix ?? + 0.055 + (Number(recipe?.density) || 0.72) * 0.1, + ), + 0, + 0.4, + ); + const detail = clamp( + Number( + composition.fieldDetail ?? + recipe?.detail ?? + recipe?.fields?.[0]?.detail ?? + 0.68, + ), + 0, + 1.5, + ); + const pressStrength = clamp( + Number(recipe?.interactions?.press?.strength ?? 0.07), + 0, + 1, + ); + const pressEffect = + recipe?.interactions?.press?.effect || "spring-compress"; + const pressProfile = PRESS_EFFECT_PROFILES[pressEffect] || { surface: 0.55 }; + const interaction = + impulse * + radialWindow * + clamp(pressStrength * 6.85, 0, 1) * + pressProfile.surface + + pointerInfluence * + pointerStrength * + 0.22 * + pointerProfile.surface; + const recipeThreshold = clamp(Number(recipe?.threshold) || 0.5, 0, 1); + const recipeDensity = clamp(Number(recipe?.density) || 0.72, 0, 1.4); + const recipeBias = (0.5 - recipeThreshold) * 0.32 + (recipeDensity - 0.72) * 0.1; + + let raw; + if (compositionMode === "field-orb") { + const normalizedX = sampleX / Math.max(0.001, orbRadius); + const normalizedY = sampleY / Math.max(0.001, orbRadius); + const normalZ = Math.sqrt( + Math.max(0, 1 - normalizedX * normalizedX - normalizedY * normalizedY), + ); + const lighting = composition.lighting || "sphere"; + let shade = 0; + if (lighting !== "flat") { + let lightX = + lighting === "crescent" + ? -0.92 + : lighting === "gibbous" + ? -0.55 + : -0.42; + let lightY = + lighting === "crescent" + ? -0.24 + : lighting === "gibbous" + ? -0.3 + : -0.48; + let lightZ = + lighting === "crescent" + ? 0.32 + : lighting === "gibbous" + ? 0.82 + : 0.76; + const lightOrbit = clamp(Number(composition.lightOrbit) || 0, 0, 0.8); + lightX += Math.cos(recipeTime * 0.6) * lightOrbit; + lightY += Math.sin(recipeTime * 0.43) * lightOrbit; + if (pointerEnabled && this.pointer.inside) { + const lightSteer = + clamp(pointerStrength / 0.16, 0, 2) * + 0.28 * + pointerProfile.light; + lightX += pointer.x * lightSteer; + lightY += pointer.y * lightSteer; + } + const lightLength = Math.hypot(lightX, lightY, lightZ) || 1; + lightX /= lightLength; + lightY /= lightLength; + lightZ /= lightLength; + shade = Math.max( + 0, + normalizedX * lightX + normalizedY * lightY + normalZ * lightZ, + ); + shade = Math.pow(shade, lighting === "crescent" ? 1.58 : 0.88); + } + const centeredField = (field - 0.5) * detail; + const voiceGlow = + clamp(Number(composition.voice) || 0, 0, 0.5) * envelope * 0.62; + const breathGlow = breathe * (envelope - 0.5) * 0.52; + const surface = + lighting === "flat" + ? 0.5 + centeredField * 0.92 + voiceGlow + breathGlow + : lighting === "crescent" + ? shade * (0.76 + centeredField * 1.08 + voiceGlow) + breathGlow + : 0.15 + + shade * 0.58 + + centeredField * 0.72 + + voiceGlow + + breathGlow; + if (usesGestaltBoundary) { + const cell = 2 / this.gridSize; + const boundaryBand = clamp(cell * 3.05, 0.055, 0.2); + const arcSupport = gestaltArcSupport( + Math.atan2(sampleY, sampleX), + context, + context.gestaltOpenness, + ); + const edgeBand = + smoothRange( + orbRadius - boundaryBand * 1.14, + orbRadius - boundaryBand * 0.4, + radius, + ) * orbMask; + const renderedSurface = surface * orbMask; + const cueNeed = + 1 - + smoothRange(0.42, 0.74, clamp01(surface + recipeBias)); + const impliedEdge = + edgeBand * + arcSupport * + (0.47 + field * 0.14 + shade * 0.05) * + cueNeed; + raw = clamp01( + Math.max(renderedSurface, impliedEdge) + + interaction + + recipeBias, + ); + } else { + const authoredRim = Number(composition.rim); + const rimWidth = clamp( + Number.isFinite(authoredRim) ? authoredRim : 0.045, + 0.012, + 0.12, + ); + const rimDistance = Math.abs(radius - orbRadius); + const rim = + 1 - smoothRange(rimWidth, rimWidth + orbEdge, rimDistance); + const rimValue = rim * (0.46 + field * 0.18); + raw = clamp01( + Math.max(surface * orbMask, rimValue) + interaction + recipeBias, + ); + } + } else { + const crispGlyph = smoothRange(0.16, 0.76, glyph); + const scaffoldDensity = clamp( + Number(recipe?.pixel?.silhouetteFloor) || 0.38, + 0.12, + 0.72, + ); + const gx = index % this.gridSize; + const gy = Math.floor(index / this.gridSize); + const scaffoldThreshold = bayer8( + gx + (this.seedHash & 7), + gy + ((this.seedHash >>> 3) & 7), + ); + const coreGlyph = smoothRange(0.56, 0.9, glyph); + const scaffold = + scaffoldThreshold < scaffoldDensity + ? coreGlyph * (0.9 + field * 0.08) + : 0; + const texturedFill = clamp01(0.1 + field * (0.82 + detail * 0.32)); + const faintCoverage = + Math.min(glyph, 0.5) * (0.68 + field * 0.18); + const surface = Math.max( + faintCoverage, + crispGlyph * texturedFill, + scaffold, + ); + const ambient = + field * + radialWindow * + ambientMix * + (1 - crispGlyph * 0.82) * + (0.72 + envelope * 0.24 + impulse * 0.5); + raw = clamp01( + Math.max(surface, ambient) + interaction + recipeBias, + ); + } + const output = result || {}; + output.raw = raw; + output.field = field; + output.glyph = glyph; + output.envelope = envelope; + output.impulse = impulse; + output.pointerInfluence = pointerInfluence; + return output; + } + + directionalThreshold(direction, x, y, time) { + switch (direction) { + case "inward": + case "reverse-to-center": + return 1 - clamp01(Math.hypot(x, y) / Math.SQRT2); + case "radial": + case "radial-tail": + case "outward-audio": + case "outward-beacon": + case "bounded-burst": + case "core-to-target": + case "shell-to-shell": + return clamp01(Math.hypot(x, y) / Math.SQRT2); + case "radar-sweep": + return fract( + fract((Math.atan2(y, x) + Math.PI) / TAU) - time * 0.12, + ); + case "bottom-to-contour": + return 1 - clamp01((y + 1) * 0.5); + case "diagonal-fracture": + case "source-to-destination": + case "tail-to-head": + return ( + clamp01((x + 1) * 0.5) + clamp01((y + 1) * 0.5) + ) * 0.5; + case "counter-flow": + return 1 - clamp01((x + 1) * 0.5); + case "columns": + case "gear-step": + return fract(clamp01((x + 1) * 0.5) * 8); + case "aperture": + case "bridge-and-retreat": + return Math.abs(clamp01((x + 1) * 0.5) - 0.5) * 2; + case "data-direction": + if (this.signalState.direction === "up") { + return clamp01((y + 1) * 0.5); + } + if (this.signalState.direction === "left") { + return clamp01((x + 1) * 0.5); + } + if (this.signalState.direction === "right") { + return 1 - clamp01((x + 1) * 0.5); + } + return 1 - clamp01((y + 1) * 0.5); + case "audio-surface": + case "contour-forward": + case "surface-drift": + return fract( + clamp01((x + 1) * 0.5) * 0.55 + + clamp01((y + 1) * 0.5) * 0.3 - + time * 0.08, + ); + case "field": + default: + return 0.5; + } + } + + switchThreshold(config, index, x, y, time, raw) { + const mode = config.mode; + if (mode === "threshold-hysteresis") return 0.5; + const random = this.random[index]; + if (mode === "seeded-dissolve") return random; + const size = this.gridSize; + const gx = index % size; + const gy = Math.floor(index / size); + const temporalRate = clamp(Number(config.temporalRate ?? 1), 0, 8); + const phase = Math.floor(time * 12 * temporalRate) & 15; + const dither = String(config.dither || "bayer8"); + const ordered = + dither === "blue-noise64" + ? hash01( + (gx + gy * 64) ^ + this.seedHash ^ + (dither.startsWith("temporal") ? phase * 0x9e3779b9 : 0), + ) + : bayer8(gx, gy); + const directional = this.directionalThreshold( + config.direction || "field", + x, + y, + time, + ); + const directedOrdered = clamp01(ordered * 0.86 + directional * 0.14); + + switch (mode) { + case "temporal-blue-noise": + return clamp01( + directedOrdered * 0.52 + + hash01(index ^ this.seedHash ^ (phase * 0x9e3779b9)) * 0.48, + ); + case "sdf-wavefront": + return clamp01( + directedOrdered * 0.78 + + Math.abs( + Math.hypot(x, y) / Math.SQRT2 - + fract(time * 0.35 * temporalRate), + ) * 0.22, + ); + case "contour-trace": + return clamp01( + directedOrdered * 0.82 + Math.abs(fract(raw * 4) - 0.5) * 0.18, + ); + case "curl-advect": + return clamp01( + directedOrdered + + Math.sin(x * 8 + y * 5 - time * 2 * temporalRate) * 0.09, + ); + case "neighbor-propagation": + return clamp01(directedOrdered - this.neighborBuffer[index] * 0.16); + case "radial-cascade": + return clamp01( + directedOrdered * 0.8 + + (Math.hypot(x, y) / Math.SQRT2) * 0.2, + ); + case "path-draw": + return clamp01(directedOrdered * 0.78 + ((x + y + 2) * 0.25) * 0.22); + case "axis-flip": + return clamp01(directedOrdered * 0.86 + (gx % 2) * 0.08); + case "field-morph": + return clamp01(directedOrdered * 0.68 + random * 0.32); + case "ordered-dither": + default: + return directedOrdered; + } + } + + updateNeighborBuffer(neighborhood = 4) { + const size = this.gridSize; + const diagonalWeight = clamp((Number(neighborhood) - 4) / 4, 0, 1); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const index = y * size + x; + let total = 0; + let count = 0; + if (x > 0) { + total += this.gates[index - 1]; + count += 1; + } + if (x < size - 1) { + total += this.gates[index + 1]; + count += 1; + } + if (y > 0) { + total += this.gates[index - size]; + count += 1; + } + if (y < size - 1) { + total += this.gates[index + size]; + count += 1; + } + if (diagonalWeight > 0) { + if (x > 0 && y > 0) { + total += this.gates[index - size - 1] * diagonalWeight; + count += diagonalWeight; + } + if (x < size - 1 && y > 0) { + total += this.gates[index - size + 1] * diagonalWeight; + count += diagonalWeight; + } + if (x > 0 && y < size - 1) { + total += this.gates[index + size - 1] * diagonalWeight; + count += diagonalWeight; + } + if (x < size - 1 && y < size - 1) { + total += this.gates[index + size + 1] * diagonalWeight; + count += diagonalWeight; + } + } + this.neighborBuffer[index] = count ? total / count : 0; + } + } + } + + evaluateGrid(dt, options = {}) { + const recipe = this.currentRecipe; + const previous = this.previousRecipe || recipe; + const samplingStride = options.forceFull + ? 1 + : this.qualityTier === "low" + ? 3 + : this.qualityTier === "balanced" + ? 2 + : 1; + const samplingPhase = samplingStride === 1 + ? 0 + : this.qualitySamplePhase % samplingStride; + if (samplingStride > 1) { + this.qualitySamplePhase = (samplingPhase + 1) % samplingStride; + } else { + this.qualitySamplePhase = 0; + } + const transitionProgress = + this.transitionDuration <= 0 + ? 1 + : clamp01(this.transitionElapsed / this.transitionDuration); + const pixelSwitch = this.resolvedPixelSwitch(recipe); + const densityBias = (this.options.density - 1) * 0.17; + const contrast = this.options.contrast; + const hysteresis = clamp( + Number( + recipe?.hysteresis ?? + recipe?.pixelSwitch?.hysteresis ?? + recipe?.pixel?.hysteresis ?? + 0.055, + ), + 0.005, + 0.2, + ); + const rise = clamp( + Number(recipe?.rise ?? recipe?.pixel?.rise ?? 0.075), + 0.025, + 0.5, + ); + const fall = clamp( + Number(recipe?.fall ?? recipe?.pixel?.fall ?? 0.14), + 0.025, + 0.7, + ); + const origin = + this.pointer.inside && !this.reducedMotion + ? { x: this.pointer.x, y: this.pointer.y } + : { x: 0, y: 0 }; + + if (pixelSwitch.mode === "neighbor-propagation") { + this.updateNeighborBuffer(pixelSwitch.neighborhood); + } + const maximumFlashes = clamp( + Number(pixelSwitch.maxContrastFlashesPerSecond ?? 3), + 0.5, + 12, + ); + const minimumDwell = 0.5 / maximumFlashes; + + let activePixels = 0; + let sampledPixels = 0; + for (let index = 0; index < this.values.length; index += 1) { + this.dwell[index] = Math.max(0, this.dwell[index] - dt); + if (samplingStride === 1 || index % samplingStride === samplingPhase) { + sampledPixels += 1; + const x = this.xs[index]; + const y = this.ys[index]; + const to = this.recipeSample( + recipe, + x, + y, + this.phase, + index, + this.sampleToScratch, + ); + let raw = to.raw; + let field = to.field; + let localProgress = 1; + if (transitionProgress < 1 && previous !== recipe) { + const from = this.recipeSample( + previous, + x, + y, + this.phase, + index, + this.sampleFromScratch, + ); + const delay = transitionDelay( + this.transitionName, + x, + y, + this.random[index], + origin, + ); + localProgress = this.reducedMotion + ? easeInOut(transitionProgress) + : easeInOut((transitionProgress - delay * 0.58) / 0.42); + raw = from.raw + (to.raw - from.raw) * localProgress; + field = from.field + (to.field - from.field) * localProgress; + } + this.transitionMixes[index] = localProgress; + raw = clamp01((raw - 0.5) * contrast + 0.5 + densityBias); + this.luminance[index] = clamp01(field * 0.45 + raw * 0.55); + + const threshold = this.switchThreshold( + pixelSwitch, + index, + x, + y, + this.phase, + raw, + ); + const gate = this.gates[index]; + if (this.dwell[index] <= 0) { + if (!gate && raw > threshold + hysteresis) { + this.gates[index] = 1; + this.dwell[index] = 0.045 + this.random[index] * 0.075; + this.afterglow[index] = 1; + } else if ( + gate && + !pixelSwitch.monotonic && + raw < threshold - hysteresis + ) { + this.gates[index] = 0; + this.dwell[index] = Math.max( + minimumDwell, + 0.06 + this.random[index] * 0.1, + ); + } + } + } + + this.afterglow[index] = Math.max( + this.gates[index], + this.afterglow[index] * Math.exp(-dt / fall), + ); + const target = this.gates[index] ? 1 : this.afterglow[index] * 0.18; + this.targets[index] = target; + const tau = target > this.values[index] ? rise : fall; + const stiffness = 1 - Math.exp(-dt / tau); + const damping = Math.exp(-dt * (target > this.values[index] ? 11 : 8)); + this.velocities[index] = + (this.velocities[index] + (target - this.values[index]) * stiffness * 22) * + damping; + this.values[index] = clamp01( + this.values[index] + this.velocities[index] * dt, + ); + if (this.values[index] > 0.32) activePixels += 1; + } + this.stats.activePixels = activePixels; + this.stats.sampledPixels = sampledPixels; + } + + settleGrid(time = this.clock, options = {}) { + const requestedTime = Number.isFinite(Number(time)) ? Number(time) : this.clock; + let representativeTime = requestedTime; + if (this.reducedMotion && requestedTime === 0) { + const reducedPhase = clamp( + Number(this.currentRecipe?.reducedMotion?.phase) || 0.25, + 0, + 1, + ); + const cycleMs = + Number(this.currentRecipe?.timeline?.macroCycleMs) || + Number(this.currentRecipe?.timeline?.durationMs) || + 4000; + representativeTime = (cycleMs / 1000) * reducedPhase; + } + this.clock = requestedTime; + this.phase = representativeTime * this.options.speed; + this.transitionElapsed = this.transitionDuration; + this.dwell.fill(0); + this.evaluateGrid(1 / 60, { forceFull: true }); + + let activePixels = 0; + for (let index = 0; index < this.values.length; index += 1) { + const target = this.gates[index] ? 1 : 0; + this.values[index] = target; + this.targets[index] = target; + this.velocities[index] = 0; + this.afterglow[index] = target; + if (target) activePixels += 1; + } + this.stats.activePixels = activePixels; + if (options.completeTransition !== false) this.finishTransition("settled"); + } + + drawPixel(shape, context, x, y, width, height, value, angle = 0) { + const scale = 0.18 + value * 0.82; + const halfWidth = (width * scale) / 2; + const halfHeight = (height * scale) / 2; + + switch (shape) { + case "square": + context.fillRect( + x - halfWidth, + y - halfHeight, + halfWidth * 2, + halfHeight * 2, + ); + break; + case "diamond": + context.save(); + context.translate(x, y); + context.rotate(Math.PI / 4); + context.fillRect( + -halfWidth * 0.74, + -halfHeight * 0.74, + halfWidth * 1.48, + halfHeight * 1.48, + ); + context.restore(); + break; + case "capsule": { + const radius = Math.min(halfWidth, halfHeight); + context.beginPath(); + context.roundRect?.( + x - halfWidth, + y - halfHeight * 0.64, + halfWidth * 2, + halfHeight * 1.28, + radius, + ); + if (!context.roundRect) { + context.ellipse(x, y, halfWidth, halfHeight * 0.64, 0, 0, TAU); + } + context.fill(); + break; + } + case "line": + context.save(); + context.translate(x, y); + if (angle) context.rotate(angle); + context.fillRect(-halfWidth, -halfHeight * 0.22, halfWidth * 2, halfHeight * 0.44); + context.restore(); + break; + case "ring": + context.lineWidth = Math.max(0.12, Math.min(width, height) * 0.14); + context.beginPath(); + context.ellipse(x, y, halfWidth, halfHeight, 0, 0, TAU); + context.stroke(); + break; + case "cross": + context.fillRect( + x - halfWidth, + y - halfHeight * 0.2, + halfWidth * 2, + halfHeight * 0.4, + ); + context.fillRect( + x - halfWidth * 0.2, + y - halfHeight, + halfWidth * 0.4, + halfHeight * 2, + ); + break; + case "disc": + default: + context.beginPath(); + context.ellipse(x, y, halfWidth, halfHeight, 0, 0, TAU); + context.fill(); + break; + } + } + + paint() { + const context = this.context; + const width = this.width || this.canvas.width / (this.dpr || 1); + const height = this.height || this.canvas.height / (this.dpr || 1); + const transitionActive = this.isTransitionActive(); + const transitionProgress = transitionActive + ? easeInOut(this.transitionElapsed / Math.max(0.001, this.transitionDuration)) + : 1; + const targetPalette = activePalette(this.currentRecipe, this.options); + const sourcePalette = transitionActive + ? activePalette(this.previousRecipe || this.currentRecipe, this.options) + : targetPalette; + const palette = transitionActive + ? mixPalette(sourcePalette, targetPalette, transitionProgress) + : targetPalette; + const pixelShape = normalizePixelShape( + this.options.pixelShape || + recipeValue(this.currentRecipe, "pixelShape", "disc"), + ); + const switchMode = this.resolvedPixelSwitch(this.currentRecipe).mode; + const { stageSize, offsetX, offsetY, cellSize: cell } = canvasGridLayout( + this.options.previewMode, + this.gridSize, + width, + height, + ); + const targetPixelScale = recipePixelScale(this.currentRecipe); + const sourcePixelScale = transitionActive + ? recipePixelScale(this.previousRecipe || this.currentRecipe) + : targetPixelScale; + const pixel = + cell * + (sourcePixelScale + + (targetPixelScale - sourcePixelScale) * transitionProgress); + + context.save(); + context.setTransform( + this.renderScaleX || this.dpr || 1, + 0, + 0, + this.renderScaleY || this.dpr || 1, + 0, + 0, + ); + context.clearRect(0, 0, width, height); + if (this.options.background && palette.background !== "transparent") { + context.fillStyle = palette.background; + context.fillRect(0, 0, width, height); + } + context.imageSmoothingEnabled = false; + + const orbBackgroundColor = safeSvgColor( + this.options.orbBackgroundColor, + "transparent", + ); + if ( + this.options.orbBackgroundMode !== "none" && + orbBackgroundColor !== "transparent" + ) { + const sourceRecipe = transitionActive + ? this.previousRecipe || this.currentRecipe + : this.currentRecipe; + const sourceRadius = this.orbBackgroundRadius(sourceRecipe, this.clock); + const targetRadius = this.orbBackgroundRadius( + this.currentRecipe, + this.clock, + ); + const sourceVisible = sourceRadius > 0; + const targetVisible = targetRadius > 0; + const orbOpacity = transitionActive + ? (sourceVisible ? 1 - transitionProgress : 0) + + (targetVisible ? transitionProgress : 0) + : targetVisible + ? 1 + : 0; + const orbRadius = + sourceVisible && targetVisible + ? sourceRadius + (targetRadius - sourceRadius) * transitionProgress + : targetVisible + ? targetRadius + : sourceRadius; + if (orbOpacity > 0.001 && orbRadius > 0) { + context.globalAlpha = clamp01(orbOpacity); + context.fillStyle = orbBackgroundColor; + if (this.options.orbBackgroundMode === "pixelated") { + drawPixelatedOrbBackground( + context, + this.gridSize, + orbRadius, + offsetX, + offsetY, + cell, + ); + } else { + context.beginPath(); + context.arc( + offsetX + stageSize / 2, + offsetY + stageSize / 2, + (stageSize * orbRadius) / 2, + 0, + TAU, + ); + context.fill(); + } + context.globalAlpha = 1; + } + } + + if (this.options.offPixels) { + const offColorKey = `${palette.off}|${palette.ink}`; + if (this.paintColorCache.off?.key !== offColorKey) { + this.paintColorCache.off = { + key: offColorKey, + value: mixColor(palette.off, palette.ink, 0.05, 0.58), + }; + } + context.fillStyle = this.paintColorCache.off.value; + const offSize = Math.max(0.16, pixel * 0.18); + for (let index = 0; index < this.values.length; index += 1) { + const gx = index % this.gridSize; + const gy = Math.floor(index / this.gridSize); + const cx = offsetX + (gx + 0.5) * cell; + const cy = offsetY + (gy + 0.5) * cell; + context.fillRect(cx - offSize / 2, cy - offSize / 2, offSize, offSize); + } + } + + const colorKey = [ + sourcePalette.ink, + sourcePalette.accent, + targetPalette.ink, + targetPalette.accent, + ].join("|"); + if (this.paintColorCache.key !== colorKey) { + const source = Array.from({ length: 9 }, (_, index) => + mixColor(sourcePalette.ink, sourcePalette.accent, index / 8, 1), + ); + const target = Array.from({ length: 9 }, (_, index) => + mixColor(targetPalette.ink, targetPalette.accent, index / 8, 1), + ); + this.paintColorCache.key = colorKey; + this.paintColorCache.source = source; + this.paintColorCache.target = target; + this.paintColorCache.steady = [target]; + this.paintColorCache.transition = null; + } + if (transitionActive && !this.paintColorCache.transition) { + const source = this.paintColorCache.source; + const target = this.paintColorCache.target; + this.paintColorCache.transition = Array.from( + { length: 9 }, + (_, transitionIndex) => + Array.from({ length: 9 }, (_, colorIndex) => + mixColor( + source[colorIndex], + target[colorIndex], + transitionIndex / 8, + 1, + ), + ), + ); + } + const transitionColorBins = transitionActive + ? this.paintColorCache.transition + : this.paintColorCache.steady; + const glowFade = clamp((72 - this.gridSize) / 24, 0, 1); + const glowEnabled = + this.qualityTier !== "low" && + this.gridSize <= 48 && + glowFade > 0.04 && + recipeValue(this.currentRecipe, "glow", true) !== false && + String(palette.glow).trim().toLowerCase() !== "transparent"; + if (glowEnabled) { + context.shadowColor = palette.glow; + const qualityGlow = this.qualityTier === "balanced" ? 0.58 : 1; + context.shadowBlur = Math.max(0.25, cell * 0.52 * glowFade * qualityGlow); + } + + for (let index = 0; index < this.values.length; index += 1) { + const value = this.values[index]; + if (value < 0.035) continue; + const gx = index % this.gridSize; + const gy = Math.floor(index / this.gridSize); + const cx = offsetX + (gx + 0.5) * cell; + const cy = offsetY + (gy + 0.5) * cell; + const luminance = this.luminance[index]; + const renderedShape = resolvePixelShape(pixelShape, luminance); + const bin = Math.min(8, Math.floor(luminance * 8.999)); + const alpha = clamp01(value * (0.72 + luminance * 0.28)); + context.globalAlpha = alpha; + const transitionBin = transitionActive + ? Math.min(8, Math.round(this.transitionMixes[index] * 8)) + : 0; + const color = transitionColorBins[transitionBin][bin]; + context.fillStyle = color; + context.strokeStyle = color; + + let widthScale = 1; + let heightScale = 1; + if ( + switchMode === "axis-flip" || + (transitionActive && this.transitionName === "axis-flip") + ) { + heightScale = Math.max(0.07, Math.sin(value * Math.PI * 0.5)); + } else if (switchMode === "path-draw") { + widthScale = 0.35 + value * 0.65; + } else if (switchMode === "radial-cascade") { + widthScale = heightScale = 0.55 + value * 0.45; + } + const angle = + renderedShape === "line" + ? Math.atan2(this.ys[index], this.xs[index]) + Math.PI / 2 + : 0; + this.drawPixel( + renderedShape, + context, + cx, + cy, + pixel * widthScale, + pixel * heightScale, + value, + angle, + ); + } + context.globalAlpha = 1; + context.shadowBlur = 0; + context.restore(); + } + + updateAdaptiveQuality(frameMs) { + if (this.options.quality !== "auto") { + this.qualityTier = this.options.quality; + this.stats.quality = this.qualityTier; + return; + } + const budget = 1000 / clamp(Number(this.options.fps) || 60, 1, 120); + if (frameMs > budget * 1.12) { + this.qualityPressure += 1; + this.qualityRecovery = Math.max(0, this.qualityRecovery - 2); + } else if (frameMs < budget * 0.62) { + this.qualityRecovery += 1; + this.qualityPressure = Math.max(0, this.qualityPressure - 1); + } else { + this.qualityPressure = Math.max(0, this.qualityPressure - 0.25); + this.qualityRecovery = Math.max(0, this.qualityRecovery - 0.5); + } + + let next = this.qualityTier; + if (this.qualityPressure >= 18) { + next = next === "high" ? "balanced" : "low"; + this.qualityPressure = 0; + this.qualityRecovery = 0; + } else if (this.qualityRecovery >= 180) { + next = next === "low" ? "balanced" : "high"; + this.qualityPressure = 0; + this.qualityRecovery = 0; + } + if (next !== this.qualityTier) { + const previous = this.qualityTier; + this.qualityTier = next; + this.emit("qualitychange", { + from: previous, + to: next, + frameMs, + budgetMs: budget, + }); + } + this.stats.quality = this.qualityTier; + } + + renderFrame(time = 0, dt = 1 / 60, force = false) { + if (this.destroyed) return this.stats; + const started = + typeof performance !== "undefined" ? performance.now() : Date.now(); + const elapsed = this.reducedMotion && !force + ? 0 + : clamp(Number(dt) || 0, 0, this.maxCatchUpSeconds); + let simulationSteps = 0; + let simulatedElapsed = 0; + if (force) { + this.simulationAccumulator = 0; + this.settleGrid(time); + } else { + this.simulationAccumulator = Math.min( + this.maxCatchUpSeconds, + this.simulationAccumulator + elapsed, + ); + while ( + this.simulationAccumulator + 1e-9 >= this.simulationStep && + simulationSteps < this.maxSimulationSteps + ) { + this.updateDynamics(this.simulationStep); + this.simulationAccumulator -= this.simulationStep; + simulationSteps += 1; + simulatedElapsed += this.simulationStep; + } + if (simulationSteps === this.maxSimulationSteps) { + this.simulationAccumulator %= this.simulationStep; + } + if (simulationSteps > 0) { + // Global clocks retain fixed 60 Hz determinism, while the expensive + // field/grid pass is coalesced to the painted frame. This prevents a + // slow frame from multiplying its own workload during catch-up. + this.evaluateGrid(simulatedElapsed); + } else { + this.stats.sampledPixels = 0; + } + } + this.stats.simulationSteps = simulationSteps; + this.paint(); + + const ended = + typeof performance !== "undefined" ? performance.now() : Date.now(); + this.stats.frameMs = ended - started; + this.updateAdaptiveQuality(this.stats.frameMs); + this.fpsAccumulator.time += elapsed; + this.fpsAccumulator.frames += 1; + if (this.fpsAccumulator.time >= 0.5) { + this.stats.fps = Math.round( + this.fpsAccumulator.frames / this.fpsAccumulator.time, + ); + this.fpsAccumulator.time = 0; + this.fpsAccumulator.frames = 0; + this.emit("stats", { ...this.stats }); + } + return { ...this.stats }; + } + + renderOnce(time = this.clock) { + return this.renderFrame(time, 0, true); + } + + inspect() { + const transitionProgress = this.transitionDuration <= 0 + ? 1 + : clamp01(this.transitionElapsed / this.transitionDuration); + return { + sprite: this.currentRecipe?.id || null, + recipeSource: this.recipeSource?.id || null, + previousSprite: this.previousRecipe?.id || null, + fieldMode: this.fieldOverride ? "override" : "recipe", + field: this.fieldOverride || this.currentRecipe?.field || null, + running: this.running, + visible: this.visible, + destroyed: this.destroyed, + reducedMotion: this.reducedMotion, + transition: { + active: this.isTransitionActive(), + name: this.transitionName, + progress: transitionProgress, + elapsed: this.transitionElapsed, + duration: this.transitionDuration, + pending: + typeof this.pendingSpriteTransition?.sprite === "string" + ? this.pendingSpriteTransition.sprite + : this.pendingSpriteTransition?.sprite?.id || null, + }, + signals: Array.from(this.signals, ([type, signal]) => ({ + type, + value: signal.value, + age: signal.age, + life: signal.life, + })), + semanticSignals: Object.fromEntries( + Array.from(this.semanticSignals, ([name, signal]) => [ + name, + signal.value, + ]), + ), + palette: { ...activePalette(this.currentRecipe, this.options) }, + orbBoundary: this.options.orbBoundary, + orbBackgroundColor: this.options.orbBackgroundColor, + orbBackgroundMode: this.options.orbBackgroundMode, + quality: { + requested: this.options.quality, + effective: this.qualityTier, + }, + stats: { ...this.stats }, + }; + } + + exportConfig() { + return { + package: "@joan/procedural-glyph-engine", + version: "5.0.0", + sprite: this.currentRecipe.id, + seed: this.seed, + gridSize: this.gridSize, + speed: this.options.speed, + density: this.options.density, + contrast: this.options.contrast, + fps: this.options.fps, + field: this.currentRecipe.field, + fieldMode: this.fieldOverride ? "override" : "recipe", + pixelShape: this.options.pixelShape || null, + pixelSwitch: + this.options.pixelSwitch && typeof this.options.pixelSwitch === "object" + ? { ...this.options.pixelSwitch } + : this.options.pixelSwitch || null, + transition: + this.options.transition && typeof this.options.transition === "object" + ? { + ...this.options.transition, + name: validateTransition(this.options.transition), + } + : this.options.transition || null, + palette: activePalette(this.currentRecipe, this.options), + paletteOverride: + this.options.palette && typeof this.options.palette === "object" + ? { ...this.options.palette } + : null, + nonErrorPalette: + this.options.nonErrorPalette && + typeof this.options.nonErrorPalette === "object" + ? { ...this.options.nonErrorPalette } + : null, + orbBoundary: this.options.orbBoundary, + orbBackgroundColor: this.options.orbBackgroundColor, + orbBackgroundMode: this.options.orbBackgroundMode, + progress: this.progress, + background: this.options.background, + offPixels: this.options.offPixels, + interactive: this.options.interactive, + autoplay: this.options.autoplay, + autoResize: this.options.autoResize, + dprMax: this.options.dprMax, + quality: this.options.quality, + reducedMotion: this.options.reducedMotion, + direction: this.options.direction || null, + targetGlyph: this.options.targetGlyph || null, + }; + } + + toSVG(options = {}) { + const size = Math.round(clamp(Number(options.size) || 512, 64, 4096)); + const palette = activeSvgPalette(this.currentRecipe, this.options); + const shape = normalizePixelShape( + this.options.pixelShape || + recipeValue(this.currentRecipe, "pixelShape", "disc"), + ); + const margin = size * 0.075; + const stage = size - margin * 2; + const cell = stage / this.gridSize; + const pixel = cell * recipePixelScale(this.currentRecipe); + const switchMode = recipePixelSwitch( + this.currentRecipe, + this.options.pixelSwitch, + ).mode; + const parts = [ + ``, + `${escapeXml(semanticLabel(this.currentRecipe))}`, + ]; + if (options.background !== false && palette.background !== "transparent") { + parts.push( + ``, + ); + } + const orbBackgroundColor = safeSvgColor( + this.options.orbBackgroundColor, + "transparent", + ); + const orbRadius = this.orbBackgroundRadius(this.currentRecipe, this.clock); + if ( + this.options.orbBackgroundMode !== "none" && + orbBackgroundColor !== "transparent" && + orbRadius > 0 + ) { + if (this.options.orbBackgroundMode === "pixelated") { + parts.push( + svgPixelatedOrbBackground( + this.gridSize, + orbRadius, + margin, + cell, + orbBackgroundColor, + ), + ); + } else { + parts.push( + ``, + ); + } + } + if (options.offPixels ?? this.options.offPixels) { + const offSize = Math.max(0.16, pixel * 0.18); + parts.push( + ``, + ); + for (let index = 0; index < this.values.length; index += 1) { + const gx = index % this.gridSize; + const gy = Math.floor(index / this.gridSize); + const cx = margin + (gx + 0.5) * cell; + const cy = margin + (gy + 0.5) * cell; + parts.push( + ``, + ); + } + parts.push(""); + } + parts.push( + ``, + ); + for (let index = 0; index < this.values.length; index += 1) { + const value = this.values[index]; + if (value < 0.035) continue; + const gx = index % this.gridSize; + const gy = Math.floor(index / this.gridSize); + const cx = margin + (gx + 0.5) * cell; + const cy = margin + (gy + 0.5) * cell; + const scale = 0.18 + value * 0.82; + let widthScale = 1; + let heightScale = 1; + if (switchMode === "axis-flip") { + heightScale = Math.max(0.07, Math.sin(value * Math.PI * 0.5)); + } else if (switchMode === "path-draw") { + widthScale = 0.35 + value * 0.65; + } else if (switchMode === "radial-cascade") { + widthScale = heightScale = 0.55 + value * 0.45; + } + const width = pixel * scale * widthScale; + const height = pixel * scale * heightScale; + const opacity = clamp01(value * (0.72 + this.luminance[index] * 0.28)); + const colorBin = Math.min( + 8, + Math.floor(this.luminance[index] * 8.999), + ); + const color = mixColor( + palette.ink, + palette.accent, + colorBin / 8, + 1, + ); + const attributes = `opacity="${opacity.toFixed(3)}"`; + const renderedShape = resolvePixelShape(shape, this.luminance[index]); + const ringStrokeWidth = + renderedShape === "ring" + ? Math.max( + 0.12, + Math.min(pixel * widthScale, pixel * heightScale) * 0.14, + ) + : null; + const angle = + renderedShape === "line" + ? ((Math.atan2(this.ys[index], this.xs[index]) + Math.PI / 2) * 180) / + Math.PI + : 0; + parts.push( + `${svgShapeMarkup( + renderedShape, + cx, + cy, + width, + height, + attributes, + "", + angle, + ringStrokeWidth, + )}`, + ); + } + parts.push(""); + return parts.join(""); + } + + async toAnimatedSVG(options = {}) { + if (typeof document === "undefined") { + throw new Error("Animated SVG export requires a browser canvas."); + } + const duration = clamp(Number(options.duration) || 2.4, 0.5, 8); + const fps = Math.round(clamp(Number(options.fps) || 12, 4, 30)); + const steps = Math.ceil(duration * fps); + const frames = steps + 1; + const gridSize = Math.min( + this.gridSize, + Math.round(clamp(Number(options.maxGridSize) || 48, 12, 64)), + ); + const scratchCanvas = document.createElement("canvas"); + scratchCanvas.width = 512; + scratchCanvas.height = 512; + const clone = new JoanGlyphEngine(scratchCanvas, { + ...this.exportConfig(), + sprite: this.currentRecipe, + gridSize, + autoplay: false, + autoResize: false, + interactive: false, + reducedMotion: false, + dprMax: 1, + quality: "low", + }); + clone.customGlyphs = new Map(this.customGlyphs); + clone.progress = this.progress; + clone.audioLevel = this.audioLevel; + const samples = Array.from( + { length: gridSize * gridSize }, + () => new Float32Array(frames), + ); + const luminanceSamples = Array.from( + { length: gridSize * gridSize }, + () => new Float32Array(frames), + ); + const orbRadiusSamples = new Float32Array(frames); + try { + for (let frame = 0; frame < steps; frame += 1) { + if (frame > 0) clone.renderFrame(frame / fps, 1 / fps, false); + orbRadiusSamples[frame] = clone.orbBackgroundRadius( + clone.currentRecipe, + frame / fps, + ); + for (let index = 0; index < clone.values.length; index += 1) { + samples[index][frame] = clone.values[index]; + luminanceSamples[index][frame] = clone.luminance[index]; + } + if (frame % 8 === 7) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + const closureFrames = Math.min(3, Math.max(1, Math.floor(steps * 0.12))); + for ( + let frame = Math.max(1, steps - closureFrames); + frame < steps; + frame += 1 + ) { + const amount = easeInOut( + (frame - (steps - closureFrames)) / closureFrames, + ); + orbRadiusSamples[frame] += + (orbRadiusSamples[0] - orbRadiusSamples[frame]) * amount; + } + orbRadiusSamples[steps] = orbRadiusSamples[0]; + for (let index = 0; index < samples.length; index += 1) { + for ( + let frame = Math.max(1, steps - closureFrames); + frame < steps; + frame += 1 + ) { + const amount = easeInOut( + (frame - (steps - closureFrames)) / closureFrames, + ); + samples[index][frame] += + (samples[index][0] - samples[index][frame]) * amount; + luminanceSamples[index][frame] += + (luminanceSamples[index][0] - luminanceSamples[index][frame]) * + amount; + } + samples[index][steps] = samples[index][0]; + luminanceSamples[index][steps] = luminanceSamples[index][0]; + } + const size = Math.round(clamp(Number(options.size) || 512, 64, 2048)); + const palette = activeSvgPalette(this.currentRecipe, this.options); + const margin = size * 0.075; + const stage = size - margin * 2; + const cell = stage / gridSize; + const pixel = cell * recipePixelScale(this.currentRecipe); + const shape = normalizePixelShape( + this.options.pixelShape || + recipeValue(this.currentRecipe, "pixelShape", "disc"), + ); + const switchMode = recipePixelSwitch( + this.currentRecipe, + this.options.pixelSwitch, + ).mode; + const keyTimes = Array.from({ length: frames }, (_, index) => + (index / Math.max(1, frames - 1)).toFixed(4), + ).join(";"); + const parts = [ + ``, + `${escapeXml(semanticLabel(this.currentRecipe))}`, + ]; + if (options.background !== false && palette.background !== "transparent") { + parts.push( + ``, + ); + } + const orbBackgroundColor = safeSvgColor( + this.options.orbBackgroundColor, + "transparent", + ); + const maximumOrbRadius = Math.max(...orbRadiusSamples); + if ( + this.options.orbBackgroundMode !== "none" && + orbBackgroundColor !== "transparent" && + maximumOrbRadius > 0 + ) { + if (this.options.orbBackgroundMode === "pixelated") { + parts.push( + animatedSvgPixelatedOrbBackground( + gridSize, + orbRadiusSamples, + margin, + cell, + orbBackgroundColor, + keyTimes, + duration, + ), + ); + } else { + const radiusValues = Array.from(orbRadiusSamples, (radius) => + ((stage * radius) / 2).toFixed(3), + ).join(";"); + parts.push( + ``, + ); + } + } + if (options.offPixels ?? this.options.offPixels) { + const offSize = Math.max(0.16, pixel * 0.18); + parts.push( + ``, + ); + for (let index = 0; index < samples.length; index += 1) { + const gx = index % gridSize; + const gy = Math.floor(index / gridSize); + const cx = margin + (gx + 0.5) * cell; + const cy = margin + (gy + 0.5) * cell; + parts.push( + ``, + ); + } + parts.push(""); + } + parts.push( + ``, + ); + for (let index = 0; index < samples.length; index += 1) { + const values = samples[index]; + const luminances = luminanceSamples[index]; + let maximum = 0; + for (let frame = 0; frame < frames; frame += 1) { + maximum = Math.max(maximum, values[frame]); + } + if (maximum < 0.03) continue; + const gx = index % gridSize; + const gy = Math.floor(index / gridSize); + const cx = margin + (gx + 0.5) * cell; + const cy = margin + (gy + 0.5) * cell; + const opacityValues = Array.from(values, (value, frame) => + clamp01(value * (0.72 + luminances[frame] * 0.28)).toFixed(3), + ); + const opacities = opacityValues.join(";"); + const ringStrokeWidths = []; + const scales = Array.from(values, (value) => { + const scale = 0.18 + clamp01(value) * 0.82; + let widthScale = 1; + let heightScale = 1; + if (switchMode === "axis-flip") { + heightScale = Math.max( + 0.07, + Math.sin(clamp01(value) * Math.PI * 0.5), + ); + } else if (switchMode === "path-draw") { + widthScale = 0.35 + clamp01(value) * 0.65; + } else if (switchMode === "radial-cascade") { + widthScale = heightScale = 0.55 + clamp01(value) * 0.45; + } + ringStrokeWidths.push( + Math.max( + 0.12, + Math.min(pixel * widthScale, pixel * heightScale) * 0.14, + ).toFixed(3), + ); + return `${(scale * widthScale).toFixed(3)} ${( + scale * heightScale + ).toFixed(3)}`; + }).join(";"); + const initialScales = scales.split(";")[0].split(" "); + const colors = Array.from(luminances, (luminance) => + mixColor(palette.ink, palette.accent, luminance, 1), + ); + const colorValues = colors + .map((color) => escapeXmlAttribute(color)) + .join(";"); + const angle = + shape === "line" + ? ((Math.atan2(gy + 0.5 - gridSize / 2, gx + 0.5 - gridSize / 2) + + Math.PI / 2) * + 180) / + Math.PI + : 0; + const opacityAnimation = ``; + const scaleAnimation = ``; + const fillAnimation = ``; + const strokeAnimation = ``; + const localShape = animatedSvgShapeMarkup( + shape, + pixel, + pixel, + luminances, + keyTimes, + duration, + angle, + ringStrokeWidths, + ); + parts.push( + `${opacityAnimation}${fillAnimation}${strokeAnimation}${scaleAnimation}${localShape}`, + ); + } + parts.push(""); + return parts.join(""); + } finally { + clone.destroy(); + } + } + + async downloadSVG( + filename = `${this.currentRecipe.id.replaceAll(".", "-")}.svg`, + options, + ) { + const source = this.toSVG(options); + if (typeof document === "undefined") return source; + const blob = new Blob([source], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + } finally { + URL.revokeObjectURL(url); + } + return source; + } + + async downloadAnimatedSVG( + filename = `${this.currentRecipe.id.replaceAll(".", "-")}.animated.svg`, + options, + ) { + const source = await this.toAnimatedSVG(options); + const blob = new Blob([source], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + } finally { + URL.revokeObjectURL(url); + } + return source; + } + + toDataURL(type = "image/png", quality) { + return this.canvas.toDataURL(type, quality); + } + + toBlob(type = "image/png", quality) { + return new Promise((resolve, reject) => { + this.canvas.toBlob?.( + (blob) => (blob ? resolve(blob) : reject(new Error("Canvas export failed."))), + type, + quality, + ); + }); + } + + async downloadPNG(filename = `${this.currentRecipe.id.replaceAll(".", "-")}.png`) { + if (typeof document === "undefined") return this.toBlob(); + const blob = await this.toBlob(); + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + } finally { + URL.revokeObjectURL(url); + } + return blob; + } + + destroy() { + if (this.destroyed) return; + this.pause(); + this.cancelReducedCrossfade(); + this.destroyed = true; + for (const remove of this.listeners.splice(0)) remove(); + for (const remove of this.interactionListeners.splice(0)) remove(); + this.resizeObserver?.disconnect(); + this.intersectionObserver?.disconnect(); + this.customGlyphs.clear(); + this.impulses.length = 0; + this.emit("destroy", {}); + } +} + +export function createGlyph(target, options = {}) { + const canvas = + typeof target === "string" + ? typeof document !== "undefined" + ? document.querySelector(target) + : null + : target; + if (!canvas) { + throw new TypeError(`No canvas matched "${String(target)}".`); + } + return new JoanGlyphEngine(canvas, options); +} + +export function createProceduralGlyph(input = {}, options = {}) { + if (input?.canvas) { + const { canvas, ...engineOptions } = input; + return createGlyph(canvas, engineOptions); + } + return createGlyph(input, options); +} + +export function mountProceduralGlyph(target, options = {}) { + return createGlyph(target, options); +} + +export { SPRITES, getSprite, listSprites }; +export default JoanGlyphEngine; diff --git a/web/vendor/src/preview-layout.js b/web/vendor/src/preview-layout.js new file mode 100644 index 0000000..b4657e0 --- /dev/null +++ b/web/vendor/src/preview-layout.js @@ -0,0 +1,63 @@ +export const PREVIEW_MODES = Object.freeze(["fit", "actual"]); + +const DEFAULT_RESOLUTION = 68; +const MIN_RESOLUTION = 8; +const MAX_RESOLUTION = 96; +const FIT_INSET = 0.075; + +function finite(value, fallback) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; +} + +export function normalizePreviewMode(mode) { + return mode === "actual" ? "actual" : "fit"; +} + +export function normalizePreviewResolution(resolution) { + return Math.min( + MAX_RESOLUTION, + Math.max( + MIN_RESOLUTION, + Math.round(finite(resolution, DEFAULT_RESOLUTION)), + ), + ); +} + +export function previewModeDetails(mode, resolution) { + const normalizedMode = normalizePreviewMode(mode); + const normalizedResolution = normalizePreviewResolution(resolution); + return { + mode: normalizedMode, + resolution: normalizedResolution, + cellSize: normalizedMode === "actual" ? 1 : null, + footprint: normalizedMode === "actual" ? normalizedResolution : null, + }; +} + +export function canvasGridLayout(mode, resolution, width, height) { + const details = previewModeDetails(mode, resolution); + const safeWidth = Math.max(1, finite(width, details.resolution)); + const safeHeight = Math.max(1, finite(height, details.resolution)); + + if (details.mode === "actual") { + const stageSize = details.resolution; + return { + ...details, + stageSize, + cellSize: 1, + offsetX: Math.round((safeWidth - stageSize) / 2), + offsetY: Math.round((safeHeight - stageSize) / 2), + }; + } + + const shortSide = Math.min(safeWidth, safeHeight); + const margin = shortSide * FIT_INSET; + const stageSize = shortSide - margin * 2; + return { + ...details, + stageSize, + cellSize: stageSize / details.resolution, + offsetX: (safeWidth - stageSize) / 2, + offsetY: (safeHeight - stageSize) / 2, + }; +} diff --git a/web/vendor/src/preview-thumbnails.js b/web/vendor/src/preview-thumbnails.js new file mode 100644 index 0000000..d14af5f --- /dev/null +++ b/web/vendor/src/preview-thumbnails.js @@ -0,0 +1,82 @@ +const DEFAULT_PREVIEW_PROGRESS = 0.48; + +function clamp01(value) { + return Math.min(1, Math.max(0, Number(value) || 0)); +} + +function representativeTime(recipe) { + const phase = clamp01(recipe?.reducedMotion?.phase ?? 0.25); + const cycleMs = + Number(recipe?.timeline?.macroCycleMs) || + Number(recipe?.timeline?.durationMs) || + 4000; + return (Math.max(1, cycleMs) / 1000) * phase; +} + +/** + * One-shot recipes may advance to a different state while their card is active. + * Atlas previews retain the requested recipe so their identity and resting frame + * always match the card label. + */ +export function createThumbnailRecipe(recipe) { + if (!recipe?.timeline?.next) return recipe; + return { + ...recipe, + timeline: { + ...recipe.timeline, + next: null, + }, + }; +} + +export function shouldAnimateThumbnail( + id, + { + selectedId = null, + mainRunning = false, + hoveredIds = new Set(), + focusedIds = new Set(), + } = {}, +) { + return ( + (id === selectedId && mainRunning) || + hoveredIds.has(id) || + focusedIds.has(id) + ); +} + +/** + * Render a deterministic, authored representative phase without leaving the + * preview in reduced-motion mode. JoanGlyphEngine maps time zero to each + * recipe's reducedMotion.phase while reduced motion is enabled. + */ +export function renderThumbnailRestFrame( + preview, + { progress = DEFAULT_PREVIEW_PROGRESS } = {}, +) { + if (!preview || preview.destroyed) return null; + + const nextProgress = clamp01(progress); + if (Math.abs(preview.progress - nextProgress) > 1e-6) { + preview.setProgress(nextProgress); + } + + // Neighbor-propagation previews otherwise retain their previous gate buffer, + // making the supposedly static pose depend on how long the card was hovered. + preview.stateElapsed = representativeTime(preview.currentRecipe); + preview.gates?.fill(0); + preview.neighborBuffer?.fill(0); + preview.dwell?.fill(0); + + const previousReducedMotion = preview.reducedMotion; + preview.reducedMotion = true; + let stats; + try { + stats = preview.renderOnce(0); + } finally { + preview.reducedMotion = previousReducedMotion; + } + + if (preview.canvas?.dataset) preview.canvas.dataset.previewState = "ready"; + return stats; +} diff --git a/web/vendor/src/sprites.js b/web/vendor/src/sprites.js new file mode 100644 index 0000000..6174119 --- /dev/null +++ b/web/vendor/src/sprites.js @@ -0,0 +1,1766 @@ +/** + * Immutable semantic recipes for Orby. + * + * A sprite is intentionally data, not a baked animation. Renderers combine its + * glyph silhouette, field stack, switching operator, transition contract, and + * interaction mappings. This keeps the catalogue portable across Canvas 2D, + * WebGL, SVG exporters, sprite-sheet bakers, and reduced-motion renderers. + */ + +const deepFreeze = (value, seen = new WeakSet()) => { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") || + seen.has(value) + ) { + return value; + } + seen.add(value); + for (const child of Object.values(value)) deepFreeze(child, seen); + return Object.freeze(value); +}; + +export const FIELD_IDS = Object.freeze([ + "fbm", + "ridged", + "domain-warp", + "curl", + "flow", + "worley", + "voronoi", + "plasma", + "interference", + "vortex", + "metaballs", + "caustics", + "strata", + "radar", + "constellation", + "liquid", + "electric", + "ripple", + "kaleidoscope", +]); + +const FIELD_ID_SET = new Set(FIELD_IDS); + +const PALETTE_SOURCE = { + neutral: { + name: "neutral", + background: "#07090c", + shadow: "#1b222b", + ink: "#eef3f8", + accent: "#93cfff", + }, + info: { + name: "info", + background: "#071019", + shadow: "#15344a", + ink: "#edf8ff", + accent: "#4fc3ff", + }, + success: { + name: "success", + background: "#07110e", + shadow: "#16392d", + ink: "#f0fff9", + accent: "#54d99b", + }, + warning: { + name: "warning", + background: "#151006", + shadow: "#493514", + ink: "#fff9e9", + accent: "#f5b94c", + }, + danger: { + name: "danger", + background: "#15090b", + shadow: "#4c1c25", + ink: "#fff2f4", + accent: "#ff647c", + }, + celebration: { + name: "celebration", + background: "#100a19", + shadow: "#352052", + ink: "#fff7ff", + accent: "#cf86ff", + }, +}; + +export const PALETTES = deepFreeze(PALETTE_SOURCE); + +function layer(field, weight, scale, blend = "add", options = {}) { + if (!FIELD_ID_SET.has(field)) { + throw new TypeError(`Unknown field "${field}" in built-in sprite recipe.`); + } + return { + field, + weight, + scale, + blend, + ...(Number.isFinite(options.segments) + ? { segments: Math.round(options.segments) } + : {}), + phase: options.phase ?? 0, + speed: options.speed ?? 1, + contrast: options.contrast ?? 1, + warp: options.warp ?? 0, + }; +} + +const defaultPointer = { + enabled: true, + minSize: 20, + effect: "steer-light", + strength: 0.16, + reducedMotion: false, +}; + +const defaultPress = { + enabled: true, + effect: "spring-compress", + strength: 0.07, + durationMs: 180, +}; + +function defineSprite({ + id, + category, + meaning, + glyph, + field, + fieldMix, + composition = {}, + palette = "neutral", + threshold = 0.5, + density = 0.72, + speed = 1, + pixelShape = "rounded-square", + pixelGap = 0.12, + pixelScale = 0.94, + silhouetteFloor = 0.38, + pixelSwitch = {}, + timeline = {}, + transition = {}, + interactions = {}, + reducedMotion = {}, + labels, + terminal = false, + urgency = "normal", +}) { + if (!FIELD_ID_SET.has(field)) { + throw new TypeError(`Unknown primary field "${field}" for "${id}".`); + } + if (!labels?.default) { + throw new TypeError(`Sprite "${id}" must define labels.default.`); + } + + const mix = fieldMix?.length + ? fieldMix + : [layer(field, 1, 1, "replace")]; + const totalWeight = mix.reduce((sum, item) => sum + item.weight, 0); + const compositionConfig = + typeof composition === "string" + ? { mode: composition } + : composition && typeof composition === "object" + ? composition + : {}; + + const recipe = { + schemaVersion: 1, + id, + semantic: { + category, + meaning, + terminal, + urgency, + announce: labels.live ?? (urgency === "high" ? "assertive" : "polite"), + decorativeCanvas: true, + monochromeSafe: true, + minimumReadableSize: 16, + }, + glyph, + field, + composition: { + mode: "glyph", + ...compositionConfig, + }, + fieldMix: mix.map((item) => ({ + ...item, + weight: totalWeight > 0 ? item.weight / totalWeight : 0, + })), + palette: PALETTES[palette] ?? PALETTES.neutral, + threshold, + density, + speed, + pixel: { + shape: pixelShape, + gap: pixelGap, + scale: pixelScale, + quantize: "1bit", + silhouetteFloor, + }, + pixelSwitch: { + mode: pixelSwitch.mode ?? "threshold-hysteresis", + dither: pixelSwitch.dither ?? "bayer8", + hysteresis: pixelSwitch.hysteresis ?? 0.055, + temporalRate: pixelSwitch.temporalRate ?? speed, + neighborhood: pixelSwitch.neighborhood ?? 0, + direction: pixelSwitch.direction ?? "field", + monotonic: pixelSwitch.monotonic ?? false, + maxContrastFlashesPerSecond: + pixelSwitch.maxContrastFlashesPerSecond ?? 3, + }, + timeline: { + mode: timeline.mode ?? "loop", + durationMs: timeline.durationMs ?? Math.round(1000 / Math.max(speed, 0.01)), + macroCycleMs: timeline.macroCycleMs ?? null, + holdMs: timeline.holdMs ?? 0, + replay: timeline.replay ?? "continuous", + next: timeline.next ?? null, + }, + transition: { + enter: transition.enter ?? "field-morph", + exit: transition.exit ?? "field-morph", + durationMs: transition.durationMs ?? 360, + exitDurationMs: + transition.exitDurationMs ?? transition.durationMs ?? 360, + preservePhase: transition.preservePhase ?? true, + preserveBuffer: transition.preserveBuffer ?? false, + interruptible: transition.interruptible ?? true, + }, + interactions: { + pointer: { + ...defaultPointer, + ...(interactions.pointer ?? {}), + }, + press: { + ...defaultPress, + ...(interactions.press ?? {}), + }, + signals: interactions.signals ?? [], + }, + reducedMotion: { + mode: reducedMotion.mode ?? "representative-phase", + phase: reducedMotion.phase ?? 0.25, + speed: 0, + transition: reducedMotion.transition ?? "crossfade", + transitionMs: reducedMotion.transitionMs ?? 120, + pointer: false, + spatialMotion: false, + representation: + reducedMotion.representation ?? `static ${labels.default.toLowerCase()} glyph`, + dataUpdates: reducedMotion.dataUpdates ?? "discrete", + }, + labels: { + default: labels.default, + aria: labels.aria ?? labels.default, + live: labels.live ?? (urgency === "high" ? "assertive" : "polite"), + value: labels.value ?? null, + variants: labels.variants ?? null, + }, + }; + + return deepFreeze(recipe); +} + +const RECIPES = [ + defineSprite({ + id: "ai.idle", + category: "presence", + meaning: "ready and available", + glyph: "idle", + field: "fbm", + composition: { + gestaltBoundary: true, + gestaltOpenness: 0.58, + }, + fieldMix: [ + layer("fbm", 0.72, 1.15, "replace", { speed: 0.2, contrast: 0.9 }), + layer("caustics", 0.28, 1.7, "soft-light", { + speed: -0.09, + phase: 0.19, + }), + ], + palette: "neutral", + threshold: 0.51, + density: 0.68, + speed: 0.14, + pixelShape: "rounded-square", + pixelSwitch: { + mode: "ordered-dither", + dither: "bayer8", + hysteresis: 0.045, + temporalRate: 0.1, + direction: "field", + }, + timeline: { + durationMs: 7200, + macroCycleMs: 14400, + replay: "continuous", + }, + transition: { + enter: "field-morph", + exit: "field-morph", + durationMs: 520, + preservePhase: true, + }, + interactions: { + pointer: { effect: "steer-highlight", strength: 0.14 }, + press: { effect: "spring-compress", strength: 0.055 }, + }, + reducedMotion: { + phase: 0.18, + representation: "static side-lit lunar sphere with a persistent rim", + }, + labels: { default: "Ready", live: "polite" }, + }), + + defineSprite({ + id: "ai.ambient-idle", + category: "ambient", + meaning: "calm field-only presence", + glyph: "idle", + field: "fbm", + composition: { + mode: "field-orb", + gestaltBoundary: true, + gestaltOpenness: 0.46, + lighting: "crescent", + radius: 0.88, + fieldContrast: 1.26, + fieldDetail: 0.86, + lightOrbit: 0.1, + breathe: 0.045, + rim: 0.055, + }, + fieldMix: [ + layer("fbm", 0.62, 1.14, "replace", { + speed: 0.18, + contrast: 1.24, + }), + layer("caustics", 0.38, 2.05, "soft-light", { + speed: -0.11, + contrast: 1.08, + phase: 0.31, + }), + ], + palette: "neutral", + threshold: 0.49, + density: 0.76, + speed: 0.22, + pixelShape: "rounded-square", + pixelScale: 0.9, + pixelSwitch: { + mode: "ordered-dither", + dither: "bayer8", + hysteresis: 0.045, + temporalRate: 0.18, + direction: "surface-drift", + }, + timeline: { + durationMs: 7200, + macroCycleMs: 14400, + replay: "continuous", + }, + transition: { + enter: "field-morph", + exit: "field-morph", + durationMs: 520, + preservePhase: true, + }, + interactions: { + pointer: { effect: "steer-light", strength: 0.18 }, + press: { effect: "surface-ripple", strength: 0.09 }, + }, + reducedMotion: { + phase: 0.18, + representation: "static side-lit dithered field orb", + }, + labels: { default: "Ambient ready", live: "polite" }, + }), + + defineSprite({ + id: "ai.ambient-thinking", + category: "ambient", + meaning: "reasoning expressed only through an animated field", + glyph: "thinking", + field: "domain-warp", + composition: { + mode: "field-orb", + gestaltBoundary: true, + gestaltOpenness: 0.62, + lighting: "sphere", + radius: 0.84, + fieldContrast: 1.48, + fieldDetail: 0.96, + lightOrbit: 0.2, + breathe: 0.09, + rim: 0.045, + }, + fieldMix: [ + layer("domain-warp", 0.46, 1.08, "replace", { + speed: 0.52, + contrast: 1.34, + warp: 0.7, + }), + layer("curl", 0.32, 1.52, "add", { + speed: -0.38, + contrast: 1.22, + }), + layer("ridged", 0.22, 2.18, "soft-light", { + speed: 0.24, + contrast: 1.36, + }), + ], + palette: "info", + threshold: 0.5, + density: 0.8, + speed: 0.72, + pixelShape: "rounded-square", + pixelScale: 0.89, + pixelSwitch: { + mode: "temporal-blue-noise", + dither: "blue-noise64", + hysteresis: 0.048, + temporalRate: 0.68, + direction: "counter-flow", + }, + timeline: { + durationMs: 4600, + macroCycleMs: 9200, + replay: "continuous", + }, + transition: { + enter: "radial-cascade", + exit: "field-morph", + durationMs: 460, + preservePhase: true, + }, + interactions: { + pointer: { effect: "bend-flow-and-light", strength: 0.16 }, + press: { effect: "damped-vortex", strength: 0.18, durationMs: 460 }, + signals: [ + { name: "reasoning.depth", mapsTo: "shellEnergy", range: [0, 1] }, + ], + }, + reducedMotion: { + phase: 0.34, + representation: "static front-lit dithered reasoning field", + }, + labels: { default: "Ambient thinking", live: "polite" }, + }), + + defineSprite({ + id: "ai.ambient-thinking-symmetric", + category: "ambient", + meaning: "reasoning expressed through a clean, symmetrical animated field", + glyph: "thinking", + field: "kaleidoscope", + composition: { + mode: "field-orb", + lighting: "flat", + radius: 0.87, + fieldContrast: 1.32, + fieldDetail: 0.84, + lightOrbit: 0, + breathe: 0.045, + rim: 0.04, + }, + fieldMix: [ + layer("kaleidoscope", 0.74, 0.82, "replace", { + segments: 8, + speed: 0.2, + contrast: 1.25, + }), + layer("kaleidoscope", 0.26, 1.42, "soft-light", { + segments: 8, + speed: -0.12, + contrast: 1.08, + }), + ], + palette: "info", + threshold: 0.5, + density: 0.78, + speed: 0.5, + pixelShape: "square", + pixelGap: 0.14, + pixelScale: 0.9, + pixelSwitch: { + mode: "threshold-hysteresis", + dither: "bayer8", + hysteresis: 0.058, + temporalRate: 0.34, + direction: "radial", + }, + timeline: { + durationMs: 6400, + macroCycleMs: 12800, + replay: "continuous", + }, + transition: { + enter: "radial-cascade", + exit: "field-morph", + durationMs: 500, + preservePhase: true, + }, + interactions: { + pointer: { enabled: false }, + press: { enabled: false }, + signals: [ + { name: "reasoning.depth", mapsTo: "shellEnergy", range: [0, 1] }, + ], + }, + reducedMotion: { + phase: 0.25, + representation: "static symmetrical reasoning field", + }, + labels: { default: "Ambient thinking — symmetric", live: "polite" }, + }), + + defineSprite({ + id: "ai.ambient-speaking", + category: "ambient", + meaning: "voice output expressed only through an animated field", + glyph: "speaking", + field: "interference", + composition: { + mode: "field-orb", + gestaltBoundary: true, + gestaltOpenness: 0.5, + lighting: "gibbous", + radius: 0.87, + fieldContrast: 1.38, + fieldDetail: 0.92, + lightOrbit: 0.13, + breathe: 0.065, + voice: 0.2, + rim: 0.05, + }, + fieldMix: [ + layer("interference", 0.46, 1.26, "replace", { + speed: 1.05, + contrast: 1.28, + }), + layer("plasma", 0.34, 1.7, "screen", { + speed: -0.72, + contrast: 1.18, + }), + layer("ripple", 0.2, 1.12, "add", { + speed: 0.82, + contrast: 1.24, + }), + ], + palette: "info", + threshold: 0.48, + density: 0.82, + speed: 1.02, + pixelShape: "circle", + pixelScale: 0.9, + pixelSwitch: { + mode: "ordered-dither", + dither: "bayer8", + hysteresis: 0.042, + temporalRate: 0.96, + direction: "audio-surface", + }, + timeline: { + mode: "signal-reactive", + durationMs: 1900, + macroCycleMs: 5700, + }, + transition: { + enter: "radial-cascade", + exit: "field-morph", + durationMs: 300, + preservePhase: true, + }, + interactions: { + pointer: { effect: "steer-light", strength: 0.13 }, + press: { enabled: false }, + signals: [ + { + name: "audio.level", + mapsTo: "envelope", + range: [0, 1], + decayMs: 220, + }, + { name: "audio.syllable", mapsTo: "bandEnergy", range: [0, 1] }, + ], + }, + reducedMotion: { + mode: "data-step", + phase: 0.38, + representation: "static gibbous field with discrete audio levels", + dataUpdates: "three-level-step", + }, + labels: { default: "Ambient speaking", live: "polite" }, + }), + + defineSprite({ + id: "ai.listening", + category: "activity", + meaning: "capturing voice or input", + glyph: "listening", + field: "interference", + fieldMix: [ + layer("interference", 0.46, 1.4, "replace", { + speed: 0.68, + contrast: 1.18, + }), + layer("radar", 0.32, 1.05, "add", { speed: -0.46 }), + layer("liquid", 0.22, 1.8, "soft-light", { speed: 0.3 }), + ], + palette: "info", + threshold: 0.48, + density: 0.77, + speed: 0.78, + pixelShape: "circle", + pixelSwitch: { + mode: "radial-cascade", + dither: "bayer8", + hysteresis: 0.05, + direction: "inward", + }, + timeline: { durationMs: 2400, macroCycleMs: 4800 }, + transition: { + enter: "sdf-wavefront", + exit: "radial-cascade", + durationMs: 340, + preservePhase: true, + }, + interactions: { + pointer: { effect: "ripple-bias", strength: 0.12 }, + press: { effect: "emit-inbound-ripple", strength: 0.18 }, + signals: [ + { name: "audio.level", mapsTo: "energy", range: [0, 1], decayMs: 90 }, + { name: "input.pulse", mapsTo: "ripple", range: [0, 1] }, + ], + }, + reducedMotion: { + phase: 0.36, + representation: "static listening ring with discrete level steps", + dataUpdates: "three-level-step", + }, + labels: { default: "Listening", live: "polite" }, + }), + + defineSprite({ + id: "ai.thinking", + category: "activity", + meaning: "reasoning", + glyph: "thinking", + field: "flow", + fieldMix: [ + layer("flow", 0.47, 1.35, "replace", { + speed: 0.82, + contrast: 1.08, + }), + layer("vortex", 0.35, 1.05, "add", { speed: -0.62, warp: 0.22 }), + layer("fbm", 0.18, 2.1, "soft-light", { speed: 0.25 }), + ], + palette: "info", + threshold: 0.5, + density: 0.74, + speed: 0.92, + pixelShape: "rounded-square", + pixelSwitch: { + mode: "temporal-blue-noise", + dither: "blue-noise64", + hysteresis: 0.062, + temporalRate: 0.82, + }, + timeline: { durationMs: 3100, macroCycleMs: 6200 }, + transition: { + enter: "field-morph", + exit: "field-morph", + durationMs: 430, + preservePhase: true, + }, + interactions: { + pointer: { effect: "bend-flow", strength: 0.13 }, + press: { effect: "damped-vortex", strength: 0.2, durationMs: 460 }, + }, + reducedMotion: { + phase: 0.3, + representation: "static faceted spiral", + }, + labels: { default: "Thinking", live: "polite" }, + }), + + defineSprite({ + id: "ai.thinking-deep", + category: "activity", + meaning: "deliberate extended reasoning", + glyph: "thinking-deep", + field: "domain-warp", + fieldMix: [ + layer("domain-warp", 0.46, 0.95, "replace", { + speed: 0.34, + warp: 0.48, + }), + layer("ridged", 0.31, 1.8, "multiply", { + speed: 0.18, + contrast: 1.24, + }), + layer("vortex", 0.23, 0.72, "add", { speed: -0.27 }), + ], + palette: "info", + threshold: 0.52, + density: 0.79, + speed: 0.38, + pixelShape: "diamond", + pixelGap: 0.1, + pixelSwitch: { + mode: "neighbor-propagation", + dither: "bayer8", + hysteresis: 0.07, + temporalRate: 0.36, + neighborhood: 8, + direction: "shell-to-shell", + }, + timeline: { + durationMs: 9600, + macroCycleMs: 19200, + replay: "continuous", + }, + transition: { + enter: "field-morph", + exit: "field-morph", + durationMs: 680, + preservePhase: true, + }, + interactions: { + pointer: { effect: "separate-shells", strength: 0.09 }, + press: { effect: "pinch-core", strength: 0.16, durationMs: 520 }, + signals: [ + { + name: "reasoning.depth", + mapsTo: "shellEnergy", + range: [0, 1], + }, + ], + }, + reducedMotion: { + phase: 0.42, + representation: "static nested rings with a brighter inner core", + }, + labels: { default: "Reasoning deeply", live: "polite" }, + }), + + defineSprite({ + id: "ai.still-working", + category: "activity", + meaning: "work is taking longer than expected", + glyph: "still-working", + field: "strata", + fieldMix: [ + layer("strata", 0.44, 1.2, "replace", { + speed: 0.21, + contrast: 1.08, + }), + layer("radar", 0.34, 0.82, "add", { speed: 0.17 }), + layer("flow", 0.22, 1.55, "soft-light", { speed: 0.18 }), + ], + palette: "neutral", + threshold: 0.5, + density: 0.71, + speed: 0.24, + pixelShape: "rounded-square", + pixelSwitch: { + mode: "radial-cascade", + dither: "bayer8", + hysteresis: 0.055, + temporalRate: 0.62, + direction: "outward-beacon", + }, + timeline: { + durationMs: 3200, + macroCycleMs: 3200, + replay: "continuous", + }, + transition: { + enter: "field-morph", + exit: "field-morph", + durationMs: 560, + preservePhase: false, + }, + interactions: { + pointer: { effect: "expand-beacon", strength: 0.1 }, + press: { effect: "host-action", strength: 0.05 }, + signals: [ + { name: "latency.elapsed", mapsTo: "beaconEnergy", range: [0, 1] }, + ], + }, + reducedMotion: { + phase: 0.46, + representation: "upright hourglass with settled sand and flip guides", + }, + labels: { default: "Still working", live: "polite" }, + }), + + defineSprite({ + id: "ai.loading", + category: "activity", + meaning: "indeterminate startup or wait", + glyph: "loading", + field: "plasma", + fieldMix: [ + layer("plasma", 0.58, 1.15, "replace", { + speed: 1.15, + contrast: 1.16, + }), + layer("flow", 0.42, 1.65, "add", { speed: 0.88 }), + ], + palette: "info", + threshold: 0.52, + density: 0.7, + speed: 1.38, + pixelShape: "square", + pixelSwitch: { + mode: "ordered-dither", + dither: "bayer8", + hysteresis: 0.052, + temporalRate: 1.3, + direction: "radial-tail", + }, + timeline: { durationMs: 1440, macroCycleMs: 5760 }, + transition: { + enter: "radial-cascade", + exit: "field-morph", + durationMs: 280, + preservePhase: true, + }, + interactions: { + pointer: { effect: "steer-highlight", strength: 0.08 }, + press: { enabled: false }, + }, + reducedMotion: { + phase: 0.63, + representation: "static segmented loading ring", + }, + labels: { default: "Loading", live: "polite" }, + }), + + defineSprite({ + id: "ai.progress", + category: "activity", + meaning: "determinate completion progress", + glyph: "progress", + field: "strata", + fieldMix: [ + layer("strata", 0.54, 1.25, "replace", { + speed: 0.25, + contrast: 1.1, + }), + layer("constellation", 0.46, 1.9, "add", { + speed: 0.1, + phase: 0.37, + }), + ], + palette: "info", + threshold: 0.47, + density: 0.84, + speed: 0.32, + pixelShape: "circle", + pixelSwitch: { + mode: "temporal-blue-noise", + dither: "blue-noise64", + hysteresis: 0.045, + temporalRate: 0.28, + direction: "contour-forward", + monotonic: true, + }, + timeline: { mode: "data-driven", durationMs: 240, replay: "on-update" }, + transition: { + enter: "contour-trace", + exit: "path-draw", + durationMs: 320, + preservePhase: true, + }, + interactions: { + pointer: { effect: "reveal-value", strength: 0 }, + press: { effect: "spring-compress", strength: 0.045 }, + signals: [ + { + name: "progress", + mapsTo: "progress", + range: [0, 1], + monotonic: true, + }, + ], + }, + reducedMotion: { + mode: "data-step", + phase: 0, + representation: "static progress contour updated from data", + dataUpdates: "discrete-monotonic", + }, + labels: { + default: "In progress", + value: "{percent}% complete", + live: "polite", + }, + }), + + defineSprite({ + id: "ai.generating", + category: "activity", + meaning: "producing content", + glyph: "generating", + field: "electric", + fieldMix: [ + layer("electric", 0.4, 1.75, "replace", { + speed: 0.84, + contrast: 1.23, + }), + layer("metaballs", 0.34, 1.1, "add", { + speed: 0.54, + warp: 0.18, + }), + layer("constellation", 0.26, 2.05, "screen", { + speed: 0.38, + }), + ], + palette: "info", + threshold: 0.5, + density: 0.78, + speed: 0.88, + pixelShape: "diamond", + pixelSwitch: { + mode: "curl-advect", + dither: "blue-noise64", + hysteresis: 0.058, + temporalRate: 0.8, + neighborhood: 4, + direction: "core-to-target", + }, + timeline: { + mode: "signal-reactive", + durationMs: 3600, + macroCycleMs: 7200, + }, + transition: { + enter: "seeded-dissolve", + exit: "field-morph", + durationMs: 460, + preservePhase: true, + }, + interactions: { + pointer: { effect: "attract-unlocked-pixels", strength: 0.11 }, + press: { effect: "core-pulse", strength: 0.08 }, + signals: [ + { name: "token", mapsTo: "spark", range: [0, 1], bounded: true }, + { name: "chunk", mapsTo: "spark", range: [0, 1], bounded: true }, + { name: "frame", mapsTo: "spark", range: [0, 1], bounded: true }, + { name: "stream.energy", mapsTo: "activity", range: [0, 1] }, + ], + }, + reducedMotion: { + mode: "data-step", + phase: 0.72, + representation: "target glyph filled in non-moving chunks", + dataUpdates: "chunked-fill", + }, + labels: { default: "Generating", live: "polite" }, + }), + + defineSprite({ + id: "ai.searching", + category: "activity", + meaning: "searching or retrieving information", + glyph: "searching", + field: "radar", + fieldMix: [ + layer("radar", 0.53, 0.95, "replace", { + speed: 0.72, + contrast: 1.18, + }), + layer("worley", 0.29, 1.62, "add", { speed: 0.2 }), + layer("caustics", 0.18, 2.1, "screen", { speed: 0.16 }), + ], + palette: "info", + threshold: 0.49, + density: 0.73, + speed: 0.74, + pixelShape: "circle", + pixelSwitch: { + mode: "sdf-wavefront", + dither: "bayer8", + hysteresis: 0.057, + temporalRate: 0.72, + direction: "radar-sweep", + }, + timeline: { durationMs: 3300, macroCycleMs: 6600 }, + transition: { + enter: "radial-cascade", + exit: "field-morph", + durationMs: 380, + preservePhase: true, + }, + interactions: { + pointer: { effect: "bias-decorative-beam", strength: 0.1 }, + press: { enabled: false }, + signals: [ + { + name: "search.hit", + mapsTo: "worleyBloom", + range: [0, 1], + decayMs: 620, + }, + { name: "search.results", mapsTo: "hitCount", range: [0, 8] }, + ], + }, + reducedMotion: { + phase: 0.2, + representation: "static magnifier with discrete result dots", + dataUpdates: "result-dot", + }, + labels: { default: "Searching", live: "polite" }, + }), + + defineSprite({ + id: "ai.tool-use", + category: "activity", + meaning: "executing a tool or action", + glyph: "tool-use", + field: "strata", + fieldMix: [ + layer("strata", 0.43, 1.3, "replace", { + speed: 0.46, + contrast: 1.12, + }), + layer("electric", 0.34, 1.8, "add", { speed: 0.68 }), + layer("voronoi", 0.23, 1.05, "multiply", { speed: 0.22 }), + ], + palette: "neutral", + threshold: 0.52, + density: 0.8, + speed: 0.68, + pixelShape: "square", + pixelSwitch: { + mode: "neighbor-propagation", + dither: "bayer8", + hysteresis: 0.062, + temporalRate: 0.62, + neighborhood: 6, + direction: "gear-step", + }, + timeline: { + mode: "signal-reactive", + durationMs: 2400, + macroCycleMs: 7200, + }, + transition: { + enter: "axis-flip", + exit: "field-morph", + durationMs: 420, + preservePhase: true, + }, + interactions: { + pointer: { effect: "tilt-light", strength: 0.13 }, + press: { effect: "mechanism-compress", strength: 0.09 }, + signals: [ + { name: "tool.step", mapsTo: "toothPulse", range: [0, 1] }, + { name: "tool.wait", mapsTo: "stepHold", range: [0, 1] }, + { name: "tool.result", mapsTo: "coreState", range: [0, 1] }, + ], + }, + reducedMotion: { + phase: 0.125, + representation: "static gear with data-driven step dots", + dataUpdates: "step-dot", + }, + labels: { default: "Using a tool", live: "polite" }, + }), + + defineSprite({ + id: "ai.speaking", + category: "activity", + meaning: "producing voice output", + glyph: "speaking", + field: "interference", + fieldMix: [ + layer("interference", 0.44, 1.2, "replace", { + speed: 1.05, + contrast: 1.16, + }), + layer("plasma", 0.33, 1.62, "add", { speed: 0.74 }), + layer("liquid", 0.23, 0.92, "soft-light", { + speed: 0.55, + warp: 0.2, + }), + ], + palette: "info", + threshold: 0.47, + density: 0.81, + speed: 1.08, + pixelShape: "circle", + pixelSwitch: { + mode: "radial-cascade", + dither: "bayer8", + hysteresis: 0.05, + temporalRate: 1.02, + direction: "outward-audio", + }, + timeline: { + mode: "signal-reactive", + durationMs: 1900, + macroCycleMs: 5700, + }, + transition: { + enter: "sdf-wavefront", + exit: "radial-cascade", + durationMs: 260, + preservePhase: true, + }, + interactions: { + pointer: { effect: "steer-light", strength: 0.11 }, + press: { enabled: false }, + signals: [ + { + name: "audio.level", + mapsTo: "envelope", + range: [0, 1], + decayMs: 240, + }, + { name: "audio.syllable", mapsTo: "bandEnergy", range: [0, 1] }, + ], + }, + reducedMotion: { + mode: "data-step", + phase: 0.38, + representation: "static speaker with three discrete level states", + dataUpdates: "three-level-step", + }, + labels: { default: "Speaking", live: "polite" }, + }), + + defineSprite({ + id: "ai.awaiting-input", + category: "activity", + meaning: "user action is required", + glyph: "awaiting-input", + field: "fbm", + fieldMix: [ + layer("fbm", 0.72, 0.92, "replace", { + speed: 0.18, + contrast: 0.94, + }), + layer("caustics", 0.28, 1.55, "soft-light", { + speed: 0.1, + contrast: 0.9, + }), + ], + palette: "info", + threshold: 0.48, + density: 0.8, + speed: 0.22, + pixelShape: "rounded-square", + pixelSwitch: { + mode: "threshold-hysteresis", + dither: "bayer8", + hysteresis: 0.052, + temporalRate: 0.08, + direction: "focus", + }, + timeline: { + mode: "hold", + durationMs: 1800, + macroCycleMs: 10800, + replay: "two-pulses-then-hold", + }, + transition: { + enter: "radial-cascade", + exit: "field-morph", + durationMs: 420, + preservePhase: false, + }, + interactions: { + pointer: { effect: "turn-aperture", strength: 0.1 }, + press: { + effect: "acknowledge", + strength: 0.06, + durationMs: 160, + emits: "activate", + }, + signals: [{ name: "activate", mapsTo: "acknowledgement", range: [0, 1] }], + }, + reducedMotion: { + phase: 0.44, + representation: "static question mark inside a firefly focus ring", + }, + labels: { default: "Your input is needed", live: "polite" }, + }), + + defineSprite({ + id: "status.success", + category: "status", + meaning: "completed successfully", + glyph: "success", + field: "caustics", + fieldMix: [ + layer("caustics", 0.57, 1.3, "replace", { + speed: 0.72, + contrast: 1.15, + }), + layer("constellation", 0.43, 1.75, "screen", { speed: 0.35 }), + ], + palette: "success", + threshold: 0.46, + density: 0.9, + speed: 0.82, + pixelShape: "rounded-square", + pixelSwitch: { + mode: "path-draw", + dither: "blue-noise64", + hysteresis: 0.042, + temporalRate: 1, + direction: "tail-to-head", + monotonic: false, + }, + timeline: { + mode: "one-shot", + durationMs: 500, + holdMs: 800, + replay: "on-enter", + }, + transition: { + enter: "path-draw", + exit: "field-morph", + durationMs: 500, + exitDurationMs: 300, + preservePhase: false, + }, + interactions: { + pointer: { effect: "edge-sparkle", strength: 0.04 }, + press: { enabled: false }, + }, + reducedMotion: { + mode: "static", + phase: 1, + representation: "static checkmark", + transitionMs: 120, + }, + labels: { default: "Completed", aria: "Completed successfully", live: "polite" }, + terminal: true, + }), + + defineSprite({ + id: "status.warning", + category: "status", + meaning: "attention is needed for a nonfatal issue", + glyph: "warning", + field: "strata", + fieldMix: [ + layer("strata", 0.58, 1.18, "replace", { + speed: 0.38, + contrast: 1.22, + }), + layer("interference", 0.42, 1.55, "add", { speed: 0.33 }), + ], + palette: "warning", + threshold: 0.49, + density: 0.86, + speed: 0.42, + pixelShape: "diamond", + pixelSwitch: { + mode: "contour-trace", + dither: "bayer8", + hysteresis: 0.065, + temporalRate: 0.38, + direction: "bottom-to-contour", + }, + timeline: { + mode: "one-shot-hold", + durationMs: 1100, + holdMs: 1600, + replay: "two-pulses-then-hold", + }, + transition: { + enter: "sdf-wavefront", + exit: "field-morph", + durationMs: 430, + preservePhase: false, + }, + interactions: { + pointer: { effect: "lean-highlight", strength: 0.08 }, + press: { effect: "contained-ripple", strength: 0.08 }, + }, + reducedMotion: { + mode: "static", + phase: 1, + representation: "static outlined triangle and exclamation", + }, + labels: { default: "Warning", live: "polite" }, + urgency: "medium", + }), + + defineSprite({ + id: "status.error", + category: "status", + meaning: "operation failed", + glyph: "error", + field: "electric", + fieldMix: [ + layer("electric", 0.61, 1.4, "replace", { + speed: 0.82, + contrast: 1.3, + }), + layer("ridged", 0.39, 1.85, "multiply", { + speed: 0.35, + contrast: 1.22, + }), + ], + palette: "danger", + threshold: 0.51, + density: 0.88, + speed: 0.78, + pixelShape: "square", + pixelSwitch: { + mode: "axis-flip", + dither: "bayer8", + hysteresis: 0.055, + temporalRate: 0.9, + direction: "diagonal-fracture", + }, + timeline: { + mode: "one-shot-hold", + durationMs: 410, + holdMs: 1200, + replay: "on-enter", + }, + transition: { + enter: "axis-flip", + exit: "seeded-dissolve", + durationMs: 410, + preservePhase: false, + }, + interactions: { + pointer: { effect: "edge-repel", strength: 0.055 }, + press: { enabled: false }, + }, + reducedMotion: { + mode: "static", + phase: 1, + representation: "static X", + transitionMs: 100, + }, + labels: { default: "Error", aria: "Operation failed", live: "assertive" }, + terminal: true, + urgency: "high", + }), + + defineSprite({ + id: "status.paused", + category: "status", + meaning: "work is suspended and can resume", + glyph: "paused", + field: "fbm", + fieldMix: [ + layer("fbm", 0.55, 1.1, "replace", { speed: 0 }), + layer("strata", 0.45, 1.4, "multiply", { speed: 0 }), + ], + palette: "neutral", + threshold: 0.5, + density: 0.9, + speed: 0, + pixelShape: "rounded-square", + pixelSwitch: { + mode: "axis-flip", + dither: "bayer8", + hysteresis: 0.08, + temporalRate: 0, + direction: "columns", + }, + timeline: { + mode: "hold", + durationMs: 1, + replay: "resume-reversible", + }, + transition: { + enter: "axis-flip", + exit: "axis-flip", + durationMs: 280, + preservePhase: true, + preserveBuffer: true, + }, + interactions: { + pointer: { effect: "steer-light-only", strength: 0.06 }, + press: { effect: "bar-squeeze", strength: 0.045 }, + signals: [{ name: "resume", mapsTo: "reverseTransition", range: [0, 1] }], + }, + reducedMotion: { + mode: "static", + phase: 1, + representation: "static pause bars", + }, + labels: { default: "Paused", live: "polite" }, + }), + + defineSprite({ + id: "status.cancelled", + category: "status", + meaning: "operation was stopped", + glyph: "cancelled", + field: "vortex", + fieldMix: [ + layer("vortex", 0.57, 0.88, "replace", { + speed: -0.68, + contrast: 1.12, + }), + layer("metaballs", 0.43, 1.25, "add", { speed: -0.42 }), + ], + palette: "neutral", + threshold: 0.52, + density: 0.85, + speed: 0.62, + pixelShape: "square", + pixelSwitch: { + mode: "seeded-dissolve", + dither: "blue-noise64", + hysteresis: 0.065, + temporalRate: 0.72, + direction: "reverse-to-center", + }, + timeline: { + mode: "one-shot-hold", + durationMs: 360, + holdMs: 900, + replay: "on-enter", + }, + transition: { + enter: "seeded-dissolve", + exit: "seeded-dissolve", + durationMs: 360, + preservePhase: false, + }, + interactions: { + pointer: { enabled: false }, + press: { effect: "spring-compress", strength: 0.035 }, + }, + reducedMotion: { + mode: "static", + phase: 1, + representation: "static stop square", + }, + labels: { default: "Cancelled", live: "polite" }, + terminal: true, + }), + + defineSprite({ + id: "status.offline", + category: "status", + meaning: "disconnected or unavailable", + glyph: "offline", + field: "radar", + fieldMix: [ + layer("radar", 0.56, 0.72, "replace", { + speed: 0.16, + contrast: 1.16, + }), + layer("electric", 0.44, 1.55, "add", { speed: 0.11 }), + ], + palette: "neutral", + threshold: 0.53, + density: 0.72, + speed: 0.18, + pixelShape: "rounded-square", + pixelSwitch: { + mode: "path-draw", + dither: "bayer8", + hysteresis: 0.075, + temporalRate: 0.16, + direction: "bridge-and-retreat", + }, + timeline: { + mode: "signal-reactive", + durationMs: 4200, + macroCycleMs: 8400, + replay: "continuous", + }, + transition: { + enter: "contour-trace", + exit: "field-morph", + durationMs: 480, + preservePhase: true, + }, + interactions: { + pointer: { effect: "weak-echo", strength: 0.045 }, + press: { effect: "host-retry", strength: 0.04 }, + signals: [ + { name: "network.retry", mapsTo: "retryEnergy", range: [0, 1] }, + { name: "network.strength", mapsTo: "signalStrength", range: [0, 1] }, + ], + }, + reducedMotion: { + phase: 0.31, + representation: "static broken ring with a diagonal disconnect mark", + dataUpdates: "retry-dot", + }, + labels: { default: "Offline", live: "polite" }, + urgency: "medium", + }), + + defineSprite({ + id: "transfer.active", + category: "workflow", + meaning: "uploading, downloading, or synchronizing data", + glyph: "transfer", + field: "flow", + fieldMix: [ + layer("flow", 0.46, 1.15, "replace", { + speed: 0.88, + contrast: 1.14, + }), + layer("liquid", 0.31, 1.55, "add", { speed: 0.65 }), + layer("strata", 0.23, 1.9, "multiply", { speed: 0.44 }), + ], + palette: "info", + threshold: 0.48, + density: 0.79, + speed: 0.9, + pixelShape: "square", + pixelSwitch: { + mode: "axis-flip", + dither: "blue-noise64", + hysteresis: 0.052, + temporalRate: 0.85, + direction: "data-direction", + monotonic: false, + }, + timeline: { + mode: "data-or-loop", + durationMs: 2200, + macroCycleMs: 6600, + }, + transition: { + enter: "axis-flip", + exit: "field-morph", + durationMs: 380, + preservePhase: true, + }, + interactions: { + pointer: { effect: "lateral-wind", strength: 0.07, minSize: 28 }, + press: { effect: "spring-compress", strength: 0.045 }, + signals: [ + { + name: "progress", + mapsTo: "progress", + range: [0, 1], + monotonic: true, + }, + { + name: "direction", + mapsTo: "direction", + values: ["up", "down", "sync"], + }, + ], + }, + reducedMotion: { + mode: "data-step", + phase: 0.5, + representation: "static directional arrow with data-driven fill", + dataUpdates: "discrete-monotonic", + }, + labels: { + default: "Transferring", + live: "polite", + value: "{percent}% transferred", + variants: { + up: "Uploading", + down: "Downloading", + sync: "Syncing", + }, + }, + }), + + defineSprite({ + id: "workflow.handoff", + category: "workflow", + meaning: "passing work to another agent or person", + glyph: "handoff", + field: "flow", + fieldMix: [ + layer("flow", 0.44, 1.02, "replace", { + speed: 0.66, + contrast: 1.1, + }), + layer("constellation", 0.34, 1.6, "screen", { speed: 0.31 }), + layer("metaballs", 0.22, 0.85, "add", { speed: 0.38 }), + ], + palette: "info", + threshold: 0.49, + density: 0.77, + speed: 0.68, + pixelShape: "circle", + pixelSwitch: { + mode: "path-draw", + dither: "blue-noise64", + hysteresis: 0.055, + temporalRate: 0.62, + direction: "source-to-destination", + }, + timeline: { + mode: "signal-reactive", + durationMs: 3000, + macroCycleMs: 6000, + replay: "loop-until-accepted", + }, + transition: { + enter: "field-morph", + exit: "path-draw", + durationMs: 460, + preservePhase: true, + }, + interactions: { + pointer: { effect: "bend-path-midpoint", strength: 0.09 }, + press: { enabled: false }, + signals: [ + { + name: "handoff.accepted", + mapsTo: "accepted", + range: [0, 1], + latches: true, + }, + ], + }, + reducedMotion: { + phase: 0.58, + representation: "two static nodes joined by a directional arrow", + dataUpdates: "destination-highlight", + }, + labels: { default: "Handing off", live: "polite" }, + }), + + defineSprite({ + id: "status.celebration", + category: "status", + meaning: "a milestone or high-value success", + glyph: "celebration", + field: "constellation", + fieldMix: [ + layer("constellation", 0.45, 1.45, "replace", { + speed: 0.92, + contrast: 1.2, + }), + layer("caustics", 0.31, 1.9, "screen", { speed: 0.62 }), + layer("electric", 0.24, 2.2, "add", { speed: 0.74 }), + ], + palette: "celebration", + threshold: 0.45, + density: 0.88, + speed: 1, + pixelShape: "diamond", + pixelSwitch: { + mode: "radial-cascade", + dither: "blue-noise64", + hysteresis: 0.045, + temporalRate: 1.1, + direction: "bounded-burst", + }, + timeline: { + mode: "one-shot", + durationMs: 700, + holdMs: 900, + replay: "on-enter-once", + next: "status.success", + }, + transition: { + enter: "radial-cascade", + exit: "field-morph", + durationMs: 700, + preservePhase: false, + }, + interactions: { + pointer: { effect: "push-fragments", strength: 0.06, minSize: 32 }, + press: { enabled: false }, + }, + reducedMotion: { + mode: "static", + phase: 1, + representation: "static star with a central success mark", + transitionMs: 120, + }, + labels: { + default: "Milestone completed", + aria: "Milestone completed successfully", + live: "polite", + }, + terminal: true, + }), +]; + +export const SPRITES = deepFreeze( + Object.fromEntries(RECIPES.map((recipe) => [recipe.id, recipe])), +); + +export const SPRITE_IDS = Object.freeze(RECIPES.map((recipe) => recipe.id)); +const SPRITE_LIST = Object.freeze([...RECIPES]); + +export const SPRITE_ALIASES = Object.freeze({ + idle: "ai.idle", + ready: "ai.idle", + ambient: "ai.ambient-idle", + "ambient-idle": "ai.ambient-idle", + "field-idle": "ai.ambient-idle", + "ambient-thinking": "ai.ambient-thinking", + "field-thinking": "ai.ambient-thinking", + "ambient-thinking-symmetric": "ai.ambient-thinking-symmetric", + "field-thinking-symmetric": "ai.ambient-thinking-symmetric", + "symmetric-thinking": "ai.ambient-thinking-symmetric", + "ambient-speaking": "ai.ambient-speaking", + "field-speaking": "ai.ambient-speaking", + listening: "ai.listening", + thinking: "ai.thinking", + "thinking-deep": "ai.thinking-deep", + "deep-thinking": "ai.thinking-deep", + "still-working": "ai.still-working", + working: "ai.still-working", + loading: "ai.loading", + progress: "ai.progress", + generating: "ai.generating", + searching: "ai.searching", + "tool-use": "ai.tool-use", + tool: "ai.tool-use", + speaking: "ai.speaking", + "awaiting-input": "ai.awaiting-input", + prompt: "ai.awaiting-input", + success: "status.success", + warning: "status.warning", + error: "status.error", + paused: "status.paused", + cancelled: "status.cancelled", + offline: "status.offline", + transfer: "transfer.active", + upload: "transfer.active", + download: "transfer.active", + sync: "transfer.active", + handoff: "workflow.handoff", + celebration: "status.celebration", +}); + +export function resolveSpriteId(id) { + const normalized = String(id ?? "").trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(SPRITES, normalized)) { + return normalized; + } + return SPRITE_ALIASES[normalized]; +} + +export function getSprite(id) { + const resolved = resolveSpriteId(id); + return resolved ? SPRITES[resolved] : undefined; +} + +export function listSprites() { + return SPRITE_LIST; +} + +export function hasSprite(id) { + return resolveSpriteId(id) !== undefined; +} + +export default SPRITES; diff --git a/web/vendor/src/state-director.js b/web/vendor/src/state-director.js new file mode 100644 index 0000000..de83a28 --- /dev/null +++ b/web/vendor/src/state-director.js @@ -0,0 +1,287 @@ +function detailEvent(type, detail) { + if (typeof CustomEvent === "function") { + return new CustomEvent(type, { detail }); + } + const event = new Event(type); + Object.defineProperty(event, "detail", { value: detail }); + return event; +} + +function abortError(signal) { + if (signal?.reason instanceof Error) return signal.reason; + const message = signal?.reason ? String(signal.reason) : "The operation was aborted."; + if (typeof DOMException === "function") { + return new DOMException(message, "AbortError"); + } + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +function stateId(sprite) { + return typeof sprite === "string" ? sprite : sprite?.id || String(sprite); +} + +/** + * Optional semantic timing helper. The host explicitly begins and completes work; + * the director only handles presentation thresholds and never infers AI state. + */ +export class StateDirector extends EventTarget { + constructor(engine, options = {}) { + super(); + if (!engine?.setSprite) throw new TypeError("StateDirector requires an engine."); + this.engine = engine; + this.options = { + deepThinkingAfterMs: 7000, + stillWorkingAfterMs: 18000, + transition: "field-morph", + ...options, + }; + this.timers = new Set(); + this.state = engine.currentRecipe?.id || "ai.idle"; + this.operation = 0; + this.destroyed = false; + } + + clearTimers() { + for (const timer of this.timers) clearTimeout(timer); + this.timers.clear(); + } + + schedule(callback, delay) { + const timer = setTimeout(() => { + this.timers.delete(timer); + callback(); + }, Math.max(0, Number(delay) || 0)); + this.timers.add(timer); + return timer; + } + + changeState(sprite, engineOptions = {}, metadata = {}) { + if (metadata.clearTimers !== false) this.clearTimers(); + const from = this.state; + const to = stateId(sprite); + this.state = to; + this.engine.setSprite(sprite, engineOptions); + this.dispatchEvent( + detailEvent("statechange", { + state: to, + from, + to, + reason: metadata.reason || "set", + }), + ); + return this; + } + + set(sprite, options = {}) { + this.operation += 1; + return this.changeState( + sprite, + { + transition: options.transition || this.options.transition, + preservePhase: options.preservePhase ?? true, + duration: options.duration, + }, + { reason: options.reason || "set" }, + ); + } + + startThinking(options, operation) { + this.changeState( + "ai.thinking", + { + transition: options.transition || this.options.transition, + preservePhase: options.preservePhase ?? true, + duration: options.duration, + }, + { reason: options.reason || "thinking-start" }, + ); + this.schedule(() => { + if (this.operation !== operation || this.state !== "ai.thinking") return; + this.changeState( + "ai.thinking-deep", + { transition: "neighbor-ignite", preservePhase: true }, + { clearTimers: false, reason: "thinking-escalation" }, + ); + }, options.deepThinkingAfterMs ?? this.options.deepThinkingAfterMs); + this.schedule(() => { + if ( + this.operation !== operation || + !["ai.thinking", "ai.thinking-deep"].includes(this.state) + ) { + return; + } + this.changeState( + "ai.still-working", + { transition: "radial-cascade", preservePhase: true }, + { clearTimers: false, reason: "thinking-escalation" }, + ); + }, options.stillWorkingAfterMs ?? this.options.stillWorkingAfterMs); + return this; + } + + beginThinking(options = {}) { + const operation = ++this.operation; + return this.startThinking(options, operation); + } + + finish(options = {}, reason = "complete") { + const sprite = options.sprite || "status.success"; + this.changeState( + sprite, + { + transition: options.transition || "path-draw", + preservePhase: options.preservePhase ?? true, + duration: options.duration, + }, + { reason }, + ); + this.engine.signal?.("complete", { energy: 1 }); + return this; + } + + complete(options = {}) { + this.operation += 1; + return this.finish(options, options.reason || "complete"); + } + + reject(options = {}, reason = "failure") { + return this.changeState( + options.sprite || "status.error", + { + transition: options.transition || "glitch-bands", + preservePhase: options.preservePhase ?? true, + duration: options.duration, + }, + { reason }, + ); + } + + fail(options = {}) { + this.operation += 1; + return this.reject(options, options.reason || "failure"); + } + + cancel(options = {}) { + this.operation += 1; + return this.changeState( + options.sprite || "status.cancelled", + { + transition: options.transition || "seeded-dissolve", + preservePhase: options.preservePhase ?? true, + duration: options.duration, + }, + { reason: options.reason || "cancelled" }, + ); + } + + reset(options = {}) { + this.operation += 1; + return this.changeState( + options.sprite || "ai.idle", + { + transition: options.transition || "field-morph", + preservePhase: options.preservePhase ?? false, + duration: options.duration, + }, + { reason: options.reason || "reset" }, + ); + } + + /** + * Present one asynchronous task through thinking escalation and a terminal + * state. The work receives `{ signal, director, engine }`; existing zero-arg + * functions remain valid because JavaScript ignores extra arguments. + */ + async run(work, options = {}) { + if (this.destroyed) throw new Error("StateDirector has been destroyed."); + if (typeof work !== "function" && !work?.then) { + throw new TypeError("StateDirector.run requires a function or Promise."); + } + + const signal = options.signal; + const operation = ++this.operation; + if (signal?.aborted) { + if (this.operation === operation) { + this.changeState( + options.cancel?.sprite || options.cancelledSprite || "status.cancelled", + { + transition: options.cancel?.transition || "seeded-dissolve", + preservePhase: options.cancel?.preservePhase ?? true, + duration: options.cancel?.duration, + }, + { reason: "run-aborted" }, + ); + } + throw abortError(signal); + } + + this.startThinking(options.thinking || options, operation); + let removeAbortListener = () => {}; + let task; + try { + task = + typeof work === "function" + ? Promise.resolve().then(() => + work({ signal, director: this, engine: this.engine }), + ) + : Promise.resolve(work); + + if (signal) { + const aborted = new Promise((_, reject) => { + const onAbort = () => reject(abortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", onAbort); + }); + task = Promise.race([task, aborted]); + } + + const result = await task; + if (this.operation === operation) { + this.finish( + { + ...(options.complete || {}), + sprite: options.complete?.sprite || options.successSprite, + }, + "run-complete", + ); + } + return result; + } catch (error) { + if (this.operation === operation) { + if (signal?.aborted || error?.name === "AbortError") { + this.changeState( + options.cancel?.sprite || options.cancelledSprite || "status.cancelled", + { + transition: options.cancel?.transition || "seeded-dissolve", + preservePhase: options.cancel?.preservePhase ?? true, + duration: options.cancel?.duration, + }, + { reason: "run-aborted" }, + ); + } else { + this.reject( + { + ...(options.fail || {}), + sprite: options.fail?.sprite || options.errorSprite, + }, + "run-failed", + ); + } + } + throw error; + } finally { + removeAbortListener(); + } + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.operation += 1; + this.clearTimers(); + } +} + +export default StateDirector; diff --git a/web/vendor/src/state-sequence.js b/web/vendor/src/state-sequence.js new file mode 100644 index 0000000..8ce49cb --- /dev/null +++ b/web/vendor/src/state-sequence.js @@ -0,0 +1,649 @@ +export const STATE_SEQUENCE_SCHEMA_VERSION = 1; + +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); + +function detailEvent(type, detail) { + if (typeof CustomEvent === "function") { + return new CustomEvent(type, { detail }); + } + const event = new Event(type); + Object.defineProperty(event, "detail", { value: detail }); + return event; +} + +function abortError(source) { + if (source?.reason instanceof Error) return source.reason; + const reason = source?.reason ?? source; + const message = reason ? String(reason) : "The state sequence was aborted."; + if (typeof DOMException === "function") { + return new DOMException(message, "AbortError"); + } + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +function stateId(sprite) { + return typeof sprite === "string" ? sprite : sprite?.id || String(sprite); +} + +function normalizeTransition(value, path) { + if (value === undefined || value === false || typeof value === "string") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${path} must be false, a transition name, or an options object.`); + } + return Object.freeze({ ...value }); +} + +function normalizeOptions(value, path) { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${path} must be an options object.`); + } + return Object.freeze({ ...value }); +} + +function normalizeStep(input, index) { + const source = + typeof input === "string" + ? { sprite: input } + : input && typeof input === "object" + ? input + : null; + const sprite = source?.sprite; + if ( + !source || + (typeof sprite === "string" + ? !sprite.trim() + : !sprite || typeof sprite !== "object") + ) { + throw new TypeError(`State sequence step ${index} requires a sprite.`); + } + + const rawHold = source.holdMs ?? source.durationMs ?? 0; + const holdMs = Number(rawHold); + if (!Number.isFinite(holdMs) || holdMs < 0) { + throw new RangeError(`State sequence step ${index} holdMs must be a non-negative number.`); + } + + const step = { + sprite: source.sprite, + holdMs, + }; + if (hasOwn(source, "transition")) { + step.transition = normalizeTransition( + source.transition, + `State sequence step ${index} transition`, + ); + } + if (hasOwn(source, "options")) { + step.options = normalizeOptions( + source.options, + `State sequence step ${index} options`, + ); + } + if (hasOwn(source, "label")) step.label = String(source.label); + if (hasOwn(source, "metadata")) { + step.metadata = normalizeOptions( + source.metadata, + `State sequence step ${index} metadata`, + ); + } + return Object.freeze(step); +} + +/** + * Create a frozen named sequence definition. Definitions that use sprite IDs + * and data-only metadata/options can be serialized directly as JSON. + * + * @example + * const readyThenDone = defineStateSequence("ready-then-done", [ + * { sprite: "ai.idle", holdMs: 5000 }, + * { sprite: "status.success", transition: "path-draw" }, + * ]); + */ +export function defineStateSequence(nameOrDefinition, steps, options = {}) { + const source = + nameOrDefinition && typeof nameOrDefinition === "object" && !Array.isArray(nameOrDefinition) + ? nameOrDefinition + : { ...options, name: nameOrDefinition, steps }; + const name = String(source.name || "").trim(); + if (!name) throw new TypeError("A state sequence requires a non-empty name."); + if ( + source.schemaVersion !== undefined && + Number(source.schemaVersion) !== STATE_SEQUENCE_SCHEMA_VERSION + ) { + throw new RangeError( + `Unsupported state sequence schema version "${String(source.schemaVersion)}".`, + ); + } + if (!Array.isArray(source.steps) || source.steps.length === 0) { + throw new TypeError(`State sequence "${name}" requires at least one step.`); + } + + const sequence = { + schemaVersion: STATE_SEQUENCE_SCHEMA_VERSION, + name, + steps: Object.freeze(source.steps.map(normalizeStep)), + transitionFirst: source.transitionFirst === true, + }; + if (hasOwn(source, "transition")) { + sequence.transition = normalizeTransition( + source.transition, + `State sequence "${name}" transition`, + ); + } + if (hasOwn(source, "metadata")) { + sequence.metadata = normalizeOptions( + source.metadata, + `State sequence "${name}" metadata`, + ); + } + return Object.freeze(sequence); +} + +function createClock(clock) { + if ( + clock && + (typeof clock.now !== "function" || + typeof clock.setTimeout !== "function" || + typeof clock.clearTimeout !== "function") + ) { + throw new TypeError( + "A custom sequence clock requires now(), setTimeout(), and clearTimeout().", + ); + } + const now = + clock + ? () => clock.now() + : () => + typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); + const schedule = + clock + ? (callback, delay) => clock.setTimeout(callback, delay) + : (callback, delay) => setTimeout(callback, delay); + const cancel = + clock + ? (timer) => clock.clearTimeout(timer) + : (timer) => clearTimeout(timer); + return Object.freeze({ now, setTimeout: schedule, clearTimeout: cancel }); +} + +function transitionOptions(specification, preservePhase, stepOptions, signal) { + const options = { preservePhase }; + if (typeof specification === "string") { + options.transition = specification; + } else if (specification && typeof specification === "object") { + Object.assign(options, specification); + if (!options.transition && typeof options.name === "string") { + options.transition = options.name; + delete options.name; + } + } + Object.assign(options, stepOptions || {}); + options.signal = signal; + return options; +} + +/** + * Plays serial state definitions against any target implementing setSprite(). + * transitionTo() is awaited when available; otherwise setSprite() is used. + */ +export class StateSequencePlayer extends EventTarget { + constructor(target, options = {}) { + super(); + if (!target || typeof target.setSprite !== "function") { + throw new TypeError("StateSequencePlayer requires a setSprite-capable target."); + } + this.target = target; + this.clock = createClock(options.clock); + this.options = Object.freeze({ + transition: hasOwn(options, "transition") + ? normalizeTransition(options.transition, "Default transition") + : "field-morph", + transitionFirst: options.transitionFirst === true, + preservePhase: options.preservePhase !== false, + }); + this.sequences = new Map(); + this.status = "idle"; + this.state = target.currentRecipe?.id || null; + this.destroyed = false; + this.finished = Promise.resolve(null); + this._active = null; + this._serial = 0; + + if (Array.isArray(options.sequences)) { + for (const sequence of options.sequences) this.register(sequence); + } else if (options.sequences && typeof options.sequences === "object") { + for (const [name, sequence] of Object.entries(options.sequences)) { + this.register( + Array.isArray(sequence) + ? defineStateSequence(name, sequence) + : defineStateSequence({ ...sequence, name: sequence.name || name }), + ); + } + } + } + + get currentSequence() { + return this._active?.sequence || null; + } + + get currentStepIndex() { + return this._active?.index ?? -1; + } + + get isPlaying() { + return this.status === "running" || this.status === "paused"; + } + + get isPaused() { + return this.status === "paused"; + } + + register(nameOrDefinition, steps, options) { + if (this.destroyed) throw new Error("StateSequencePlayer has been destroyed."); + const sequence = defineStateSequence(nameOrDefinition, steps, options); + this.sequences.set(sequence.name, sequence); + return sequence; + } + + unregister(name) { + return this.sequences.delete(String(name)); + } + + getSequence(name) { + return this.sequences.get(String(name)) || null; + } + + listSequences() { + return Object.freeze([...this.sequences.values()]); + } + + _resolveSequence(input, options) { + if (typeof input === "string") { + const sequence = this.getSequence(input); + if (!sequence) throw new RangeError(`Unknown state sequence "${input}".`); + return sequence; + } + if (Array.isArray(input)) { + return defineStateSequence(options.name || `sequence-${this._serial + 1}`, input); + } + return defineStateSequence(input); + } + + _normalizePlayOptions(options) { + const normalized = { + signal: options.signal, + preservePhase: options.preservePhase ?? this.options.preservePhase, + transitionFirst: options.transitionFirst, + }; + if (hasOwn(options, "transition")) { + normalized.transition = normalizeTransition(options.transition, "Play transition"); + } + return normalized; + } + + play(sequenceOrName, options = {}) { + if (this.destroyed) { + return Promise.reject(new Error("StateSequencePlayer has been destroyed.")); + } + + let sequence; + let playOptions; + try { + sequence = this._resolveSequence(sequenceOrName, options); + playOptions = this._normalizePlayOptions(options); + if (playOptions.signal?.aborted) throw abortError(playOptions.signal); + } catch (error) { + return Promise.reject(error); + } + + if (this._active) this._stopRun(this._active, "superseded"); + + const run = { + id: ++this._serial, + sequence, + options: playOptions, + controller: new AbortController(), + index: -1, + completedSteps: 0, + paused: false, + delay: null, + gates: new Set(), + terminal: null, + removeExternalAbort: () => {}, + }; + this._active = run; + this.status = "running"; + + if (playOptions.signal) { + const onAbort = () => this._cancelRun(run, playOptions.signal); + playOptions.signal.addEventListener("abort", onAbort, { once: true }); + run.removeExternalAbort = () => + playOptions.signal.removeEventListener("abort", onAbort); + if (playOptions.signal.aborted) this._cancelRun(run, playOptions.signal); + } + + this._emit("sequencestart", run, { totalSteps: sequence.steps.length }); + const finished = this._execute(run).then( + () => { + if (!run.terminal) this._completeRun(run); + return this._result(run); + }, + (error) => { + if (run.terminal?.status === "stopped") return this._result(run); + if (run.terminal?.status === "cancelled") throw run.terminal.error; + this._errorRun(run, error); + throw error; + }, + ).finally(() => this._finalizeRun(run)); + run.finished = finished; + this.finished = finished; + return finished; + } + + run(sequenceOrName, options = {}) { + return this.play(sequenceOrName, options); + } + + async _execute(run) { + for (let index = 0; index < run.sequence.steps.length; index += 1) { + if (run.controller.signal.aborted) throw abortError(run.controller.signal); + const step = run.sequence.steps[index]; + run.index = index; + this._emit("stepstart", run, { + step, + sprite: stateId(step.sprite), + }); + + await this._applyStep(run, step, index); + await this._pauseGate(run); + if (run.controller.signal.aborted) throw abortError(run.controller.signal); + + const from = this.state; + const to = stateId(step.sprite); + this.state = to; + this._emit("statechange", run, { from, to, state: to, step }); + this._emit("stepenter", run, { from, to, step, sprite: to }); + + if (step.holdMs > 0) await this._delay(run, step.holdMs); + await this._pauseGate(run); + if (run.controller.signal.aborted) throw abortError(run.controller.signal); + + run.completedSteps = index + 1; + this._emit("stepcomplete", run, { step, sprite: to }); + } + } + + _transitionFor(run, step, index) { + if (hasOwn(step, "transition")) return step.transition; + const transitionFirst = + run.options.transitionFirst ?? + (run.sequence.transitionFirst || this.options.transitionFirst); + if (index === 0 && !transitionFirst) return false; + if (hasOwn(run.options, "transition")) return run.options.transition; + if (hasOwn(run.sequence, "transition")) return run.sequence.transition; + return this.options.transition; + } + + async _applyStep(run, step, index) { + const specification = this._transitionFor(run, step, index); + if (specification === false) { + const options = { immediate: true, ...(step.options || {}) }; + this.target.setSprite(step.sprite, options); + return; + } + + if (typeof this.target.transitionTo !== "function") { + const options = transitionOptions( + specification, + run.options.preservePhase, + step.options, + run.controller.signal, + ); + delete options.signal; + this.target.setSprite(step.sprite, options); + return; + } + + const options = transitionOptions( + specification, + run.options.preservePhase, + step.options, + run.controller.signal, + ); + let transition; + try { + transition = this.target.transitionTo(step.sprite, options); + } catch (error) { + throw error; + } + await this._raceAbort(transition, run); + } + + _raceAbort(value, run) { + if (run.controller.signal.aborted) { + return Promise.reject(abortError(run.controller.signal)); + } + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, result) => { + if (settled) return; + settled = true; + run.controller.signal.removeEventListener("abort", onAbort); + callback(result); + }; + const onAbort = () => finish(reject, abortError(run.controller.signal)); + run.controller.signal.addEventListener("abort", onAbort, { once: true }); + Promise.resolve(value).then( + (result) => finish(resolve, result), + (error) => finish(reject, error), + ); + }); + } + + _pauseGate(run) { + if (!run.paused) return Promise.resolve(); + if (run.controller.signal.aborted) { + return Promise.reject(abortError(run.controller.signal)); + } + return new Promise((resolve, reject) => { + let settled = false; + const gate = { + resolve: () => finish(resolve), + }; + const finish = (callback, value) => { + if (settled) return; + settled = true; + run.gates.delete(gate); + run.controller.signal.removeEventListener("abort", onAbort); + callback(value); + }; + const onAbort = () => finish(reject, abortError(run.controller.signal)); + run.gates.add(gate); + run.controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + _delay(run, durationMs) { + return new Promise((resolve, reject) => { + let settled = false; + const delay = { + timer: null, + remainingMs: durationMs, + startedAt: 0, + pause: () => { + if (delay.timer === null) return; + const elapsed = Math.max(0, this.clock.now() - delay.startedAt); + delay.remainingMs = Math.max(0, delay.remainingMs - elapsed); + this.clock.clearTimeout(delay.timer); + delay.timer = null; + }, + resume: () => { + if (settled || run.paused || delay.timer !== null) return; + delay.startedAt = this.clock.now(); + delay.timer = this.clock.setTimeout(finish, delay.remainingMs); + }, + }; + const cleanup = () => { + if (delay.timer !== null) this.clock.clearTimeout(delay.timer); + delay.timer = null; + if (run.delay === delay) run.delay = null; + run.controller.signal.removeEventListener("abort", onAbort); + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + reject(abortError(run.controller.signal)); + }; + run.delay = delay; + run.controller.signal.addEventListener("abort", onAbort, { once: true }); + delay.resume(); + }); + } + + /** + * Pause sequence progression and the active hold clock. A visual transition + * already running in the target may finish, but no step is entered or + * advanced until resume() is called. + */ + pause() { + const run = this._active; + if (!run || run.terminal || run.paused) return this; + run.paused = true; + run.delay?.pause(); + this.status = "paused"; + this._emit("sequencepause", run); + return this; + } + + resume() { + const run = this._active; + if (!run || run.terminal || !run.paused) return this; + run.paused = false; + this.status = "running"; + this._emit("sequenceresume", run); + run.delay?.resume(); + for (const gate of [...run.gates]) gate.resolve(); + return this; + } + + stop(reason = "stopped") { + if (this._active) this._stopRun(this._active, reason); + return this; + } + + _stopRun(run, reason) { + if (run.terminal) return; + run.terminal = { status: "stopped", reason: String(reason || "stopped") }; + run.controller.abort(abortError(reason)); + if (this._active === run) this.status = "idle"; + this._emit("sequencestop", run, { reason: run.terminal.reason }); + } + + _cancelRun(run, signal) { + if (run.terminal) return; + const error = abortError(signal); + run.terminal = { status: "cancelled", reason: signal?.reason, error }; + run.controller.abort(error); + if (this._active === run) this.status = "idle"; + this._emit("sequencecancel", run, { reason: signal?.reason, error }); + } + + _completeRun(run) { + run.terminal = { status: "completed", reason: "completed" }; + if (this._active === run) this.status = "idle"; + this._emit("sequencecomplete", run, { + completedSteps: run.completedSteps, + }); + } + + _errorRun(run, error) { + if (run.terminal) return; + run.terminal = { status: "error", reason: "error", error }; + if (this._active === run) this.status = "idle"; + this._emit("sequenceerror", run, { error }); + } + + _result(run) { + return Object.freeze({ + runId: run.id, + name: run.sequence.name, + sequence: run.sequence, + status: run.terminal?.status || "completed", + reason: run.terminal?.reason, + completedSteps: run.completedSteps, + }); + } + + _emit(type, run, extra = {}) { + const detail = Object.freeze({ + runId: run.id, + name: run.sequence.name, + sequence: run.sequence, + index: run.index, + ...extra, + }); + this.dispatchEvent(detailEvent(type, detail)); + return detail; + } + + _finalizeRun(run) { + run.removeExternalAbort(); + run.delay?.pause(); + run.delay = null; + for (const gate of [...run.gates]) gate.resolve(); + run.gates.clear(); + if (this._active === run) { + this._active = null; + if (!this.destroyed) this.status = "idle"; + } + } + + destroy() { + if (this.destroyed) return; + if (this._active) this._stopRun(this._active, "destroyed"); + this.destroyed = true; + this.status = "destroyed"; + this.sequences.clear(); + } +} + +/** + * Ergonomic one-off playback. The returned player is also the pause/resume/stop + * handle; await player.finished for completion. + * + * @example + * const playback = playStateSequence(engine, [ + * { sprite: "ai.ambient-idle", holdMs: 1200 }, + * { sprite: "ai.progress", transition: "contour-trace" }, + * ], { signal }); + * await playback.finished; + */ +export function playStateSequence(target, steps, options = {}) { + const player = new StateSequencePlayer(target, { + clock: options.clock, + transition: hasOwn(options, "transition") ? options.transition : "field-morph", + transitionFirst: options.transitionFirst, + preservePhase: options.preservePhase, + }); + const sequence = defineStateSequence(options.name || "one-off", steps, { + metadata: options.metadata, + }); + player.finished = player.play(sequence, { signal: options.signal }); + return player; +} + +export default StateSequencePlayer; diff --git a/web/vendor/src/studio.js b/web/vendor/src/studio.js new file mode 100644 index 0000000..313f496 --- /dev/null +++ b/web/vendor/src/studio.js @@ -0,0 +1,3033 @@ +import JoanGlyphEngine, { + PIXEL_SHAPES, + PIXEL_SWITCHES, + TRANSITIONS, + listSprites, +} from "./joan-engine.js"; +import { FIELD_IDS } from "./fields.js"; +import { + normalizePreviewMode, + previewModeDetails, +} from "./preview-layout.js"; +import { + createThumbnailRecipe, + renderThumbnailRestFrame, + shouldAnimateThumbnail, +} from "./preview-thumbnails.js"; +import { + SELECT_CONTROL_HELP, + VALUE_CONTROL_HELP, + placeControlTooltip, +} from "./control-help.js"; + +const byId = (id) => document.getElementById(id); +const elements = { + heroCanvas: byId("heroEngineCanvas"), + canvas: byId("engineCanvas"), + canvasFrame: byId("canvasFrame"), + canvasHint: byId("canvasHint"), + frameRenderBadge: byId("frameRenderBadge"), + previewFitButton: byId("previewFitButton"), + previewActualButton: byId("previewActualButton"), + liveRegion: byId("liveRegion"), + stateTitle: byId("stateTitle"), + stateDescription: byId("stateDescription"), + stateCategory: byId("stateCategory"), + stateSearchInput: byId("stateSearchInput"), + statePickerSelect: byId("statePickerSelect"), + statePickerStatus: byId("statePickerStatus"), + spriteGrid: byId("spriteGrid"), + fieldSelect: byId("fieldSelect"), + fieldScopeBadge: byId("fieldScopeBadge"), + transitionSelect: byId("transitionSelect"), + transitionScopeBadge: byId("transitionScopeBadge"), + pixelShapeSelect: byId("pixelShapeSelect"), + pixelShapeScopeBadge: byId("pixelShapeScopeBadge"), + pixelSwitchSelect: byId("pixelSwitchSelect"), + pixelSwitchScopeBadge: byId("pixelSwitchScopeBadge"), + orbBoundaryControl: byId("orbBoundaryControl"), + orbBoundaryInput: byId("orbBoundaryInput"), + orbBoundaryScopeBadge: byId("orbBoundaryScopeBadge"), + orbBoundaryAvailability: byId("orbBoundaryAvailability"), + orbBackgroundControl: byId("orbBackgroundControl"), + orbBackgroundEnabledInput: byId("orbBackgroundEnabledInput"), + orbBackgroundEnabledLabel: byId("orbBackgroundEnabledLabel"), + orbBackgroundModeSelect: byId("orbBackgroundModeSelect"), + orbBackgroundColorInput: byId("orbBackgroundColorInput"), + orbBackgroundScopeBadge: byId("orbBackgroundScopeBadge"), + orbBackgroundAvailability: byId("orbBackgroundAvailability"), + nonErrorPaletteSection: byId("nonErrorPaletteSection"), + paletteScopeBadge: byId("paletteScopeBadge"), + motionScopeBadge: byId("motionScopeBadge"), + paletteInputs: [...document.querySelectorAll("[data-palette-color]")], + glowColorControl: byId("glowColorControl"), + glowColorInput: byId("glowColorInput"), + glowEnabledInput: byId("glowEnabledInput"), + glowEnabledLabel: byId("glowEnabledLabel"), + resetPaletteButton: byId("resetPaletteButton"), + resolutionRange: byId("resolutionRange"), + resolutionValue: byId("resolutionValue"), + speedRange: byId("speedRange"), + speedValue: byId("speedValue"), + densityRange: byId("densityRange"), + densityValue: byId("densityValue"), + seedInput: byId("seedInput"), + randomizeButton: byId("randomizeButton"), + overrideSummary: byId("overrideSummary"), + workflowStatus: byId("workflowStatus"), + recentPresetSelect: byId("recentPresetSelect"), + savePresetButton: byId("savePresetButton"), + savePresetGalleryButton: byId("savePresetGalleryButton"), + savedPresetGallery: byId("savedPresetGallery"), + savedPresetEmpty: byId("savedPresetEmpty"), + savedPresetCount: byId("savedPresetCount"), + deletedPresetNotice: byId("deletedPresetNotice"), + deletedPresetMessage: byId("deletedPresetMessage"), + undoDeletePresetButton: byId("undoDeletePresetButton"), + undoStudioButton: byId("undoStudioButton"), + resetOverridesButton: byId("resetOverridesButton"), + openExportButton: byId("openExportButton"), + playButton: byId("playButton"), + autopilotButton: byId("autopilotButton"), + themeButton: byId("themeButton"), + openRecipeButton: byId("openRecipeButton"), + recipeFile: byId("recipeFile"), + importButton: byId("importButton"), + glyphFile: byId("glyphFile"), + exportPngButton: byId("exportPngButton"), + exportSvgButton: byId("exportSvgButton"), + exportAnimatedSvgButton: byId("exportAnimatedSvgButton"), + exportConfigButton: byId("exportConfigButton"), + copyCodeButton: byId("copyCodeButton"), + codeSnippet: byId("codeSnippet"), + fpsValue: byId("fpsValue"), + activeValue: byId("activeValue"), + currentStateValue: byId("currentStateValue"), + progressControl: byId("progressControl"), + progressRange: byId("progressRange"), + progressValue: byId("progressValue"), + signalTokenButton: byId("signalTokenButton"), + signalSearchButton: byId("signalSearchButton"), + signalAudioButton: byId("signalAudioButton"), + exportDialog: byId("exportDialog"), + exportForm: byId("exportForm"), + closeExportButton: byId("closeExportButton"), + cancelExportButton: byId("cancelExportButton"), + exportPreviewCanvas: byId("exportPreviewCanvas"), + exportPreviewNote: byId("exportPreviewNote"), + exportFormatSelect: byId("exportFormatSelect"), + exportSizeSelect: byId("exportSizeSelect"), + exportFilenameInput: byId("exportFilenameInput"), + exportBackgroundInput: byId("exportBackgroundInput"), + exportDimensionsValue: byId("exportDimensionsValue"), + exportCompositionValue: byId("exportCompositionValue"), + exportMotionValue: byId("exportMotionValue"), + exportBackgroundValue: byId("exportBackgroundValue"), + confirmExportButton: byId("confirmExportButton"), +}; + +const sprites = listSprites(); +const spriteById = new Map(sprites.map((sprite) => [sprite.id, sprite])); +const builtInSpriteIds = new Set(sprites.map((sprite) => sprite.id)); +const STUDIO_PACKAGE = "@joan/procedural-glyph-engine"; +const STUDIO_VERSION = "5.0.0"; +const RECENT_PRESET_KEY = "joan.studio.recent-presets.v1"; +const THEME_STORAGE_KEY = "joan.studio.theme.v1"; +const MAX_RECENT_PRESETS = 24; +const SAFE_COLOR = + /^(?:#[\da-f]{3,8}|(?:rgba?|hsla?)\([\d\s.,%+\/-]+\)|transparent|aqua|black|blue|fuchsia|gray|green|grey|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow)$/i; +const humanize = (value) => + String(value) + .replaceAll(".", " · ") + .replaceAll("-", " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +const formatNumber = (value, digits = 2) => + Number(value).toFixed(digits).replace(/\.?0+$/, ""); +const supportsGestaltBoundary = (recipe) => + recipe?.composition?.gestaltBoundary === true; + +function addOptions(select, values, getLabel = humanize) { + if (!select) return; + select.textContent = ""; + for (const value of values) { + const option = document.createElement("option"); + option.value = value; + option.textContent = getLabel(value); + select.append(option); + } +} + +addOptions( + elements.fieldSelect, + ["__recipe__", ...FIELD_IDS], + (value) => (value === "__recipe__" ? "Recipe field stack" : humanize(value)), +); +addOptions( + elements.transitionSelect, + ["__recipe__", ...TRANSITIONS], + (value) => (value === "__recipe__" ? "Recipe transition" : humanize(value)), +); +const PIXEL_SHAPE_LABELS = Object.freeze({ + "square-cross": "Square + cross", + "square-cross-ring": "Square + cross + ring", +}); +addOptions( + elements.pixelShapeSelect, + PIXEL_SHAPES, + (value) => PIXEL_SHAPE_LABELS[value] || humanize(value), +); +addOptions(elements.pixelSwitchSelect, PIXEL_SWITCHES); + +const controlTipBindings = new Map(); +const CONTROL_TOOLTIP_HOVER_DELAY_MS = 1000; +let activeControlTip = null; +let dismissedControlTip = null; +let controlTipOpenTimer = null; +let controlTipCloseTimer = null; +let controlTipHideTimer = null; +const controlTipPortal = document.createElement("div"); +controlTipPortal.className = "control-tooltip"; +controlTipPortal.hidden = true; +controlTipPortal.setAttribute("aria-hidden", "true"); +document.body.append(controlTipPortal); + +function controlTipText(control) { + const selectHelp = SELECT_CONTROL_HELP[control.id]; + if (selectHelp) { + const optionHelp = selectHelp.options[control.value] || ""; + return `${selectHelp.summary} ${optionHelp}`.trim(); + } + + const base = VALUE_CONTROL_HELP[control.id] || ""; + switch (control.id) { + case "resolutionRange": + return `${base} Current grid: ${control.value}×${control.value}.`; + case "speedRange": + return `${base} Current speed: ${formatNumber(control.value)}×.`; + case "densityRange": + return `${base} Current density: ${formatNumber(control.value)}.`; + case "backgroundColorInput": + case "offColorInput": + case "inkColorInput": + case "accentColorInput": + return `${base} Current color: ${control.value.toUpperCase()}.`; + case "glowColorInput": + return control.disabled + ? `${base} Glow is currently off.` + : `${base} Current color: ${control.value.toUpperCase()}.`; + case "glowEnabledInput": + return `${base} Glow is currently ${control.checked ? "on" : "off"}.`; + case "orbBoundaryInput": + return control.disabled + ? `${base} This state does not use an orb boundary; your choice remains saved.` + : `${base} Current mode: ${control.checked ? "Gestalt" : "Defined"}.`; + case "orbBackgroundEnabledInput": + return control.disabled + ? `${base} This state does not use a configurable orb; your choice remains saved.` + : `${base} The orb background is currently ${control.checked ? "on" : "off"}.`; + case "orbBackgroundColorInput": + return control.disabled + ? `${base} Enable the orb background to choose a color.` + : `${base} Current color: ${control.value.toUpperCase()}.`; + default: + return base; + } +} + +function positionActiveControlTip() { + if (!activeControlTip || controlTipPortal.hidden) return; + const { host } = activeControlTip; + if (!host.isConnected) return; + const anchor = host.getBoundingClientRect(); + const tooltip = controlTipPortal.getBoundingClientRect(); + const position = placeControlTooltip( + anchor, + tooltip, + { + width: window.innerWidth, + height: window.innerHeight, + }, + { gap: 8, gutter: 12 }, + ); + controlTipPortal.style.left = `${position.left}px`; + controlTipPortal.style.top = `${position.top}px`; + controlTipPortal.dataset.placement = position.placement; +} + +function updateControlTip(control) { + const binding = controlTipBindings.get(control); + if (!binding) return; + const text = controlTipText(control); + binding.description.textContent = text; + if (activeControlTip?.control === control) { + controlTipPortal.textContent = text; + positionActiveControlTip(); + } +} + +function showControlTip(control) { + const binding = controlTipBindings.get(control); + if (!binding || dismissedControlTip === control) return; + window.clearTimeout(controlTipOpenTimer); + window.clearTimeout(controlTipCloseTimer); + window.clearTimeout(controlTipHideTimer); + activeControlTip = binding; + updateControlTip(control); + controlTipPortal.textContent = binding.description.textContent; + controlTipPortal.hidden = false; + controlTipPortal.classList.add("is-visible"); + positionActiveControlTip(); +} + +function hideControlTip() { + window.clearTimeout(controlTipOpenTimer); + window.clearTimeout(controlTipCloseTimer); + controlTipPortal.classList.remove("is-visible"); + activeControlTip = null; + controlTipHideTimer = window.setTimeout(() => { + if (!activeControlTip) controlTipPortal.hidden = true; + }, 150); +} + +function scheduleControlTipOpen(control) { + window.clearTimeout(controlTipOpenTimer); + controlTipOpenTimer = window.setTimeout(() => { + const binding = controlTipBindings.get(control); + if (binding?.host.matches(":hover")) showControlTip(control); + }, CONTROL_TOOLTIP_HOVER_DELAY_MS); +} + +function scheduleControlTipClose() { + window.clearTimeout(controlTipCloseTimer); + controlTipCloseTimer = window.setTimeout(() => { + const hostHasFocus = activeControlTip?.host.matches(":focus-within"); + const hostHasPointer = activeControlTip?.host.matches(":hover"); + if (!hostHasFocus && !hostHasPointer) { + hideControlTip(); + } + }, 120); +} + +function appendDescriptionId(control, id) { + const ids = new Set( + (control.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(Boolean), + ); + ids.add(id); + control.setAttribute("aria-describedby", [...ids].join(" ")); +} + +function registerControlTip(control) { + if (!control?.id || controlTipBindings.has(control)) return; + const label = control.closest("label"); + const labelControls = label?.querySelectorAll( + "button, input, select, textarea", + ); + const host = + control.matches("button") || labelControls?.length > 1 ? control : label; + if (!host) return; + + const description = document.createElement("span"); + description.className = "visually-hidden"; + description.id = `${control.id}Help`; + description.setAttribute("role", "tooltip"); + document.body.append(description); + appendDescriptionId(control, description.id); + host.classList.add("control-with-tooltip"); + + const binding = { control, host, description }; + controlTipBindings.set(control, binding); + updateControlTip(control); + + host.addEventListener("pointerenter", (event) => { + if (event.pointerType !== "touch") scheduleControlTipOpen(control); + }); + host.addEventListener("pointerleave", () => { + window.clearTimeout(controlTipOpenTimer); + if (dismissedControlTip === control) dismissedControlTip = null; + scheduleControlTipClose(); + }); + host.addEventListener("focusin", () => { + window.clearTimeout(controlTipOpenTimer); + showControlTip(control); + }); + host.addEventListener("focusout", () => { + if (dismissedControlTip === control) dismissedControlTip = null; + scheduleControlTipClose(); + }); + control.addEventListener("input", () => updateControlTip(control)); + control.addEventListener("change", () => updateControlTip(control)); +} + +function setupControlTips() { + for (const id of Object.keys(SELECT_CONTROL_HELP)) { + registerControlTip(byId(id)); + } + for (const id of Object.keys(VALUE_CONTROL_HELP)) { + registerControlTip(byId(id)); + } + + document.addEventListener("pointerdown", (event) => { + if ( + activeControlTip && + !activeControlTip.host.contains(event.target) && + !controlTipPortal.contains(event.target) + ) { + hideControlTip(); + } + }); + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !activeControlTip) return; + dismissedControlTip = activeControlTip.control; + hideControlTip(); + }); + window.addEventListener("scroll", positionActiveControlTip, { + passive: true, + }); + window.addEventListener("resize", positionActiveControlTip); +} + +setupControlTips(); + +const engine = new JoanGlyphEngine(elements.canvas, { + sprite: "ai.idle", + seed: elements.seedInput?.value || "world-class-ai", + gridSize: Number(elements.resolutionRange?.value) || 68, + speed: Number(elements.speedRange?.value) || 1, + density: Number(elements.densityRange?.value) || 1, + pixelShape: null, + pixelSwitch: null, + transition: null, + orbBoundary: "defined", + orbBackgroundColor: null, + orbBackgroundMode: "none", + ariaLive: elements.liveRegion, + quality: "auto", + dprMax: 1.5, +}); + +window.joanEngine = engine; + +let heroEngine = null; + +const previewEngines = []; +const previewById = new Map(); +const previewDescriptors = new Map(); +const hoveredPreviewIds = new Set(); +const focusedPreviewIds = new Set(); +const spriteButtons = new Map(); +const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); +let selectedSpriteId = engine.currentRecipe.id; +let autopilotTimer = null; +let autopilotIndex = 0; +let audioTimer = null; +let isLight = document.documentElement.dataset.theme === "light"; +let pixelShapeOverridden = false; +let pixelSwitchOverridden = false; +let transitionOverridden = false; +let transitionOverrideValue = null; +let customNonErrorPalette = {}; +let savedGlowOverride = null; +let hadSavedGlowOverride = false; +let orbBackgroundColorChoice = "#262626"; +let orbBackgroundModeChoice = "solid"; +let previewMode = "fit"; +let baselineStudioState = null; +let undoStack = []; +let isRestoringStudioState = false; +let recentPresets = []; +let deletedPreset = null; +const activeMutations = new Set(); +let workflowStatusTimer = null; +let exportReturnFocus = null; +let previewResolutionVersion = 0; +let previewRefreshVersion = 0; +let previewsDisposed = false; +let previewCardObserver = null; +let savedPresetPreviewObserver = null; +const savedPresetPreviewQueue = new Map(); +let pendingResolution = engine.gridSize; +let resolutionCommitTimer = null; +const RESOLUTION_COMMIT_DELAY_MS = 90; +const urgentPreviewIds = new Set(); +let urgentPreviewScheduled = false; +const previewWorkQueue = []; +let previewWorkScheduled = false; + +function scheduleIdlePreviewWork(callback) { + if (typeof window.requestIdleCallback === "function") { + window.requestIdleCallback(callback, { timeout: 240 }); + } else { + window.setTimeout(callback, 16); + } +} + +function runPreviewWorkSlice(deadline) { + if (previewsDisposed) { + previewWorkQueue.length = 0; + previewWorkScheduled = false; + return; + } + const task = previewWorkQueue.shift(); + try { + task?.(deadline); + } finally { + if (previewWorkQueue.length) scheduleIdlePreviewWork(runPreviewWorkSlice); + else previewWorkScheduled = false; + } +} + +function enqueuePreviewWork(task, { priority = false } = {}) { + if (typeof task !== "function" || previewsDisposed) return; + if (priority) previewWorkQueue.unshift(task); + else previewWorkQueue.push(task); + if (previewWorkScheduled) return; + previewWorkScheduled = true; + scheduleIdlePreviewWork(runPreviewWorkSlice); +} + +function setPreviewResolution(preview, resolution) { + if (preview.gridSize !== resolution) preview.setResolution(resolution); + if (preview.canvas?.dataset) { + preview.canvas.dataset.gridSize = String(resolution); + } +} + +function currentPreviewProgress() { + const value = Number(elements.progressRange?.value); + return Number.isFinite(value) + ? Math.min(1, Math.max(0, value / 100)) + : 0.48; +} + +function previewId(preview) { + return preview?.previewSpriteId || preview?.currentRecipe?.id || null; +} + +function previewShouldAnimate(id) { + return ( + !reduceMotion.matches && + shouldAnimateThumbnail(id, { + selectedId: selectedSpriteId, + mainRunning: engine.running, + hoveredIds: hoveredPreviewIds, + focusedIds: focusedPreviewIds, + }) + ); +} + +function freezePreview(preview) { + return renderThumbnailRestFrame(preview, { + progress: currentPreviewProgress(), + }); +} + +function freezePreviewIfInactive(id) { + const preview = previewById.get(id); + if (preview && !previewShouldAnimate(id)) freezePreview(preview); +} + +function prioritizedPreviewQueue() { + const queue = []; + const seen = new Set(); + const add = (preview) => { + if (!preview || seen.has(preview)) return; + seen.add(preview); + queue.push(preview); + }; + add(previewById.get(selectedSpriteId)); + for (const id of hoveredPreviewIds) add(previewById.get(id)); + for (const id of focusedPreviewIds) add(previewById.get(id)); + for (const preview of previewEngines) add(preview); + return queue; +} + +function schedulePreviewResolutionSync(value = engine.gridSize) { + const resolution = Math.round(Number(value) || engine.gridSize); + const version = ++previewResolutionVersion; + const queue = prioritizedPreviewQueue(); + const updateNext = () => { + if (version !== previewResolutionVersion) return; + const preview = queue.shift(); + if (!preview) return; + setPreviewResolution(preview, resolution); + if (!previewShouldAnimate(previewId(preview))) freezePreview(preview); + if (queue.length) enqueuePreviewWork(updateNext); + }; + enqueuePreviewWork(updateNext); +} + +function cancelPendingResolutionCommit() { + if (resolutionCommitTimer !== null) { + window.clearTimeout(resolutionCommitTimer); + resolutionCommitTimer = null; + } +} + +function commitPendingResolution() { + cancelPendingResolutionCommit(); + const value = Math.round(Number(pendingResolution) || engine.gridSize); + if (engine.gridSize !== value) engine.setResolution(value); + applyPreviewMode(previewMode); + scheduleStudioMetadataUpdate(); + return value; +} + +function scheduleResolutionCommit(value) { + pendingResolution = Math.round(Number(value) || engine.gridSize); + cancelPendingResolutionCommit(); + resolutionCommitTimer = window.setTimeout( + commitPendingResolution, + RESOLUTION_COMMIT_DELAY_MS, + ); +} + +function schedulePreviewRefresh() { + const version = ++previewRefreshVersion; + const queue = prioritizedPreviewQueue().filter((preview) => { + return !previewShouldAnimate(previewId(preview)); + }); + const refreshNext = () => { + if (version !== previewRefreshVersion) return; + const preview = queue.shift(); + if (!preview) return; + if (!previewShouldAnimate(previewId(preview))) freezePreview(preview); + if (queue.length) enqueuePreviewWork(refreshNext); + }; + enqueuePreviewWork(refreshNext); +} + +function applyPreviewMode(mode) { + const details = previewModeDetails(mode, engine.gridSize); + previewMode = normalizePreviewMode(details.mode); + const actual = previewMode === "actual"; + + if (engine.options.previewMode !== previewMode) { + engine.setOptions({ previewMode }); + } + elements.canvasFrame?.classList.toggle("is-actual-preview", actual); + elements.canvasFrame?.style.setProperty( + "--preview-grid-size", + `${details.resolution}px`, + ); + elements.previewFitButton?.setAttribute("aria-pressed", String(!actual)); + elements.previewActualButton?.setAttribute("aria-pressed", String(actual)); + + if (elements.previewActualButton) { + elements.previewActualButton.title = actual + ? `Actual size: ${details.resolution}×${details.resolution}px. Each grid cell uses one CSS pixel.` + : `Show actual size at ${details.resolution}×${details.resolution}px, with one CSS pixel per grid cell.`; + } + if (elements.frameRenderBadge) { + elements.frameRenderBadge.textContent = actual ? "1:1 render" : "Live render"; + } + if (elements.canvasHint) { + elements.canvasHint.textContent = actual + ? `${details.resolution}×${details.resolution}px · one cell = one CSS pixel` + : "Move · press · drop a glyph"; + } +} + +const lightPalette = { + background: "#e5e6e2", + off: "#c7cbc8", + ink: "#17191e", + variants: { + neutral: { accent: "#464c52", glow: "#8b9096" }, + info: { accent: "#50565b", glow: "#8a8f94" }, + success: { accent: "#303438", glow: "#777b7e" }, + warning: { accent: "#5b5a56", glow: "#92908a" }, + danger: { accent: "#3f4245", glow: "#838689" }, + celebration: { accent: "#585d63", glow: "#95999e" }, + }, +}; + +const darkPalette = { + background: "#070708", + off: "#1f2022", + ink: "#f2f1ee", + variants: { + neutral: { accent: "#c9c8c4", glow: "#8b8d90" }, + info: { accent: "#b7babd", glow: "#777b80" }, + success: { accent: "#d6d6d0", glow: "#959792" }, + warning: { accent: "#b9b6af", glow: "#82807b" }, + danger: { accent: "#c5c4c1", glow: "#8d8c89" }, + celebration: { accent: "#eeeeea", glow: "#aaa9a5" }, + }, +}; + +function studioPaletteOverride() { + const palette = { + ...(isLight ? lightPalette : darkPalette), + ...customNonErrorPalette, + }; + return palette; +} + +function setGlowControlEnabled(enabled) { + const isEnabled = Boolean(enabled); + if (elements.glowEnabledInput) elements.glowEnabledInput.checked = isEnabled; + if (elements.glowEnabledLabel) { + elements.glowEnabledLabel.textContent = isEnabled ? "Color" : "None"; + } + if (elements.glowColorInput) elements.glowColorInput.disabled = !isEnabled; + elements.glowColorControl?.classList.toggle("is-glow-disabled", !isEnabled); + + const output = elements.glowColorInput?.parentElement?.querySelector("output"); + if (output) { + output.textContent = isEnabled + ? elements.glowColorInput.value.toUpperCase() + : "NONE"; + } + updateControlTip(elements.glowEnabledInput); + updateControlTip(elements.glowColorInput); +} + +function normalizedOrbBackgroundColor(value) { + if (typeof value !== "string") return null; + const color = value.trim(); + return color && color.toLowerCase() !== "transparent" ? color : null; +} + +function normalizedOrbBackgroundMode(value, color = null) { + if (value === "none") return "none"; + if (value === "pixelated" || value === "solid") return value; + return normalizedOrbBackgroundColor(color) ? "solid" : "none"; +} + +function setOrbBackgroundControlState( + color = engine.options.orbBackgroundColor, + available = supportsGestaltBoundary(engine.currentRecipe), + mode = engine.options.orbBackgroundMode, +) { + const activeColor = normalizedOrbBackgroundColor(color); + const activeMode = normalizedOrbBackgroundMode(mode, activeColor); + const enabled = Boolean(activeColor) && activeMode !== "none"; + if (activeColor) orbBackgroundColorChoice = activeColor; + if (activeMode !== "none") orbBackgroundModeChoice = activeMode; + + if (elements.orbBackgroundControl) { + elements.orbBackgroundControl.dataset.available = String(available); + elements.orbBackgroundControl.dataset.enabled = String(enabled); + } + if (elements.orbBackgroundEnabledInput) { + elements.orbBackgroundEnabledInput.checked = enabled; + elements.orbBackgroundEnabledInput.disabled = !available; + } + if (elements.orbBackgroundEnabledLabel) { + elements.orbBackgroundEnabledLabel.textContent = enabled ? "On" : "None"; + } + if (elements.orbBackgroundModeSelect) { + elements.orbBackgroundModeSelect.value = + activeMode === "none" ? orbBackgroundModeChoice : activeMode; + elements.orbBackgroundModeSelect.disabled = !available || !enabled; + } + if (elements.orbBackgroundColorInput) { + if (/^#[\da-f]{6}$/i.test(activeColor || "")) { + elements.orbBackgroundColorInput.value = activeColor; + } else if (/^#[\da-f]{6}$/i.test(orbBackgroundColorChoice)) { + elements.orbBackgroundColorInput.value = orbBackgroundColorChoice; + } + elements.orbBackgroundColorInput.disabled = !available || !enabled; + const output = elements.orbBackgroundColorInput.parentElement?.querySelector( + "output", + ); + if (output) output.textContent = enabled ? activeColor.toUpperCase() : "NONE"; + } + if (elements.orbBackgroundAvailability) { + elements.orbBackgroundAvailability.textContent = available + ? enabled + ? activeMode === "pixelated" + ? "Grid-aligned disk behind the orb pixels" + : "Solid circle behind the orb pixels" + : "Optional color behind the orb pixels" + : "Available for Ready and ambient orbs · choice stays saved"; + } + updateControlTip(elements.orbBackgroundEnabledInput); + updateControlTip(elements.orbBackgroundModeSelect); + updateControlTip(elements.orbBackgroundColorInput); +} + +function syncOrbAppearanceToPreviews({ refresh = true } = {}) { + for (const preview of previewEngines) { + preview.options.orbBoundary = engine.options.orbBoundary; + preview.options.orbBackgroundColor = engine.options.orbBackgroundColor; + preview.options.orbBackgroundMode = engine.options.orbBackgroundMode; + } + if (refresh) schedulePreviewRefresh(); +} + +function updatePaletteControls(palette) { + for (const input of elements.paletteInputs) { + const key = input.dataset.paletteColor; + const value = palette?.[key]; + if (/^#[\da-f]{6}$/i.test(value || "")) input.value = value; + const output = input.parentElement?.querySelector("output"); + if (key === "glow") { + const enabled = String(value).trim().toLowerCase() !== "transparent"; + setGlowControlEnabled(enabled); + } else if (output) { + output.textContent = input.value.toUpperCase(); + } + updateControlTip(input); + } +} + +function authoredPaletteForControls(recipe) { + const palette = recipe?.palette || {}; + return { + background: palette.background, + off: palette.off || palette.shadow, + ink: palette.ink, + accent: palette.accent, + glow: palette.glow || palette.accent, + }; +} + +function nonErrorPaletteForProtectedPreview(palette) { + if (!palette || typeof palette !== "object") return palette; + return { + ...palette, + ...(palette.variants?.neutral || {}), + }; +} + +function applyStudioPalette({ + refreshPreviews = true, + immediatePreviewRefresh = false, +} = {}) { + const palette = studioPaletteOverride(); + engine.setNonErrorPalette(palette); + heroEngine?.setNonErrorPalette(palette); + for (const preview of previewEngines) { + preview.setNonErrorPalette(palette, { render: false }); + } + if (immediatePreviewRefresh) { + for (const preview of previewEngines) freezePreview(preview); + } else if (refreshPreviews) { + schedulePreviewRefresh(); + } + if (engine.currentRecipe.id !== "status.error") { + updatePaletteControls(engine.exportConfig().palette); + } else if (palette) { + updatePaletteControls(nonErrorPaletteForProtectedPreview(palette)); + } else { + updatePaletteControls(authoredPaletteForControls(spriteById.get("ai.idle"))); + } + updateWorkflowState(); + updateCode(); +} + +let paletteUpdateFrame = null; +function scheduleStudioPaletteUpdate() { + if (paletteUpdateFrame !== null) return; + paletteUpdateFrame = requestAnimationFrame(() => { + paletteUpdateFrame = null; + applyStudioPalette({ refreshPreviews: false }); + }); +} + +function flushStudioPaletteUpdate() { + if (paletteUpdateFrame !== null) { + cancelAnimationFrame(paletteUpdateFrame); + paletteUpdateFrame = null; + } + applyStudioPalette(); +} + +let studioMetadataFrame = null; +function scheduleStudioMetadataUpdate() { + if (studioMetadataFrame !== null) return; + studioMetadataFrame = requestAnimationFrame(() => { + studioMetadataFrame = null; + updateWorkflowState(); + updateCode(); + }); +} + +function recipePixelSwitch(recipe) { + const pixelSwitch = recipe?.pixelSwitch; + return typeof pixelSwitch === "string" + ? pixelSwitch + : pixelSwitch?.name || + pixelSwitch?.type || + pixelSwitch?.mode || + "ordered-dither"; +} + +function normalizeStructuredOption(value, values, keys, canonicalKey) { + if (typeof value === "string") return values.includes(value) ? value : null; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const name = keys.map((key) => value[key]).find((entry) => values.includes(entry)); + if (!name) return null; + return { ...cloneData(value), [canonicalKey]: name }; +} + +function normalizeTransitionOption(value) { + return normalizeStructuredOption( + value, + TRANSITIONS, + ["name", "type", "enter"], + "name", + ); +} + +function normalizePixelSwitchOption(value) { + return normalizeStructuredOption( + value, + PIXEL_SWITCHES, + ["mode", "name", "type"], + "mode", + ); +} + +function transitionOptionName(value) { + const normalized = normalizeTransitionOption(value); + return typeof normalized === "string" ? normalized : normalized?.name || null; +} + +function pixelSwitchOptionMode(value) { + const normalized = normalizePixelSwitchOption(value); + return typeof normalized === "string" ? normalized : normalized?.mode || null; +} + +function sameStudioValue(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function formatCodeValue(value, indent = " ") { + const serialized = JSON.stringify(value, null, 2); + return serialized ? serialized.replaceAll("\n", `\n${indent}`) : "null"; +} + +function recipeDescription(recipe) { + return ( + recipe?.semantic?.description || + recipe?.semantic?.meaning || + recipe?.description || + "A deterministic semantic sprite composed from a crisp mask or field orb, procedural texture, and persistent pixel gate." + ); +} + +function recipeLabel(recipe) { + return ( + recipe?.labels?.default || + recipe?.semantic?.label || + recipe?.label || + humanize(recipe?.id || "state") + ); +} + +function showWorkflowStatus(message, { announce = true, tone = "neutral" } = {}) { + window.clearTimeout(workflowStatusTimer); + if (elements.workflowStatus) { + elements.workflowStatus.textContent = message; + elements.workflowStatus.dataset.tone = tone; + } + if (announce && elements.liveRegion) elements.liveRegion.textContent = message; + workflowStatusTimer = window.setTimeout(() => { + if (!elements.workflowStatus) return; + elements.workflowStatus.textContent = "Ready to customize"; + delete elements.workflowStatus.dataset.tone; + }, 4200); +} + +function setScopeBadge(element, text, kind = "recipe") { + if (!element) return; + element.textContent = text; + element.dataset.scope = kind; +} + +function buildStatePicker(query = "") { + const select = elements.statePickerSelect; + if (!select) return; + const normalized = query.trim().toLowerCase(); + const matches = sprites.filter((recipe) => { + if (!normalized) return true; + return [ + recipe.id, + recipeLabel(recipe), + recipe.semantic?.category, + recipeDescription(recipe), + ] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(normalized)); + }); + + const selectedMatches = matches.some((recipe) => recipe.id === selectedSpriteId); + const groups = new Map(); + for (const recipe of matches) { + const category = humanize(recipe.semantic?.category || "Other"); + if (!groups.has(category)) groups.set(category, []); + groups.get(category).push(recipe); + } + + select.textContent = ""; + if (!selectedMatches && spriteById.has(selectedSpriteId)) { + const currentGroup = document.createElement("optgroup"); + currentGroup.label = "Current state"; + const current = spriteById.get(selectedSpriteId); + const option = document.createElement("option"); + option.value = current.id; + option.textContent = recipeLabel(current); + currentGroup.append(option); + select.append(currentGroup); + } + for (const [category, recipes] of groups) { + const group = document.createElement("optgroup"); + group.label = category; + for (const recipe of recipes) { + const option = document.createElement("option"); + option.value = recipe.id; + option.textContent = recipeLabel(recipe); + group.append(option); + } + select.append(group); + } + + if (!select.options.length) { + const option = document.createElement("option"); + option.textContent = "No matching states"; + option.disabled = true; + select.append(option); + select.disabled = true; + } else { + select.disabled = false; + select.value = selectedSpriteId; + } + if (elements.statePickerStatus) { + elements.statePickerStatus.textContent = normalized + ? `${matches.length} matching ${matches.length === 1 ? "state" : "states"}` + : `${sprites.length} states available in ${groups.size} categories`; + } +} + +function cloneData(value) { + if (typeof structuredClone === "function") return structuredClone(value); + return JSON.parse(JSON.stringify(value)); +} + +function captureStudioState() { + return { + sprite: engine.currentRecipe.id, + fieldOverride: engine.fieldOverride || null, + transitionOverride: transitionOverridden + ? normalizeTransitionOption(transitionOverrideValue) + : null, + pixelShape: engine.options.pixelShape || elements.pixelShapeSelect?.value || null, + pixelShapeOverridden, + pixelSwitch: pixelSwitchOverridden + ? normalizePixelSwitchOption(engine.options.pixelSwitch) + : null, + pixelSwitchOverridden, + orbBoundary: + engine.options.orbBoundary === "gestalt" ? "gestalt" : "defined", + orbBackgroundColor: + normalizedOrbBackgroundColor(engine.options.orbBackgroundColor), + orbBackgroundMode: normalizedOrbBackgroundMode( + engine.options.orbBackgroundMode, + engine.options.orbBackgroundColor, + ), + orbBackgroundColorChoice, + orbBackgroundModeChoice, + gridSize: engine.gridSize, + speed: Number(engine.options.speed), + density: Number(engine.options.density), + seed: engine.seed, + progress: Number(engine.progress), + palette: { ...customNonErrorPalette }, + savedGlowOverride, + hadSavedGlowOverride, + previewMode, + }; +} + +function changedSettingCount(state, baseline = baselineStudioState) { + if (!baseline) return 0; + let count = 0; + if (state.fieldOverride !== baseline.fieldOverride) count += 1; + if (!sameStudioValue(state.transitionOverride, baseline.transitionOverride)) count += 1; + if (state.pixelShape !== baseline.pixelShape) count += 1; + if ( + state.pixelSwitchOverridden !== baseline.pixelSwitchOverridden || + !sameStudioValue(state.pixelSwitch, baseline.pixelSwitch) + ) { + count += 1; + } + if (state.orbBoundary !== baseline.orbBoundary) count += 1; + if ( + state.orbBackgroundColor !== baseline.orbBackgroundColor || + state.orbBackgroundMode !== baseline.orbBackgroundMode + ) { + count += 1; + } + if (state.gridSize !== baseline.gridSize) count += 1; + if (state.speed !== baseline.speed) count += 1; + if (state.density !== baseline.density) count += 1; + if (state.seed !== baseline.seed) count += 1; + if (JSON.stringify(state.palette) !== JSON.stringify(baseline.palette)) count += 1; + return count; +} + +function updateWorkflowState() { + const state = captureStudioState(); + const changes = changedSettingCount(state); + const motionCustom = Boolean( + baselineStudioState && + (state.gridSize !== baselineStudioState.gridSize || + state.speed !== baselineStudioState.speed || + state.density !== baselineStudioState.density), + ); + const pixelShapeCustom = Boolean( + baselineStudioState && state.pixelShape !== baselineStudioState.pixelShape, + ); + + setScopeBadge( + elements.fieldScopeBadge, + state.fieldOverride ? "Custom" : "Recipe", + state.fieldOverride ? "custom" : "recipe", + ); + setScopeBadge( + elements.transitionScopeBadge, + state.transitionOverride ? "Custom" : "Recipe", + state.transitionOverride ? "custom" : "recipe", + ); + setScopeBadge( + elements.pixelShapeScopeBadge, + pixelShapeCustom ? "Custom · carries" : "Carries", + pixelShapeCustom ? "custom" : "carry", + ); + setScopeBadge( + elements.pixelSwitchScopeBadge, + state.pixelSwitchOverridden ? "Custom · carries" : "Recipe", + state.pixelSwitchOverridden ? "custom" : "recipe", + ); + const boundaryAvailable = supportsGestaltBoundary(engine.currentRecipe); + setScopeBadge( + elements.orbBoundaryScopeBadge, + boundaryAvailable ? "Carries" : "Orb only", + boundaryAvailable + ? state.orbBoundary === "gestalt" + ? "custom" + : "carry" + : "recipe", + ); + setScopeBadge( + elements.orbBackgroundScopeBadge, + boundaryAvailable + ? state.orbBackgroundMode !== "none" && state.orbBackgroundColor + ? `${state.orbBackgroundMode === "pixelated" ? "Pixels" : "Solid"} · carries` + : "None" + : "Orb only", + boundaryAvailable + ? state.orbBackgroundMode !== "none" && state.orbBackgroundColor + ? "custom" + : "carry" + : "recipe", + ); + setScopeBadge( + elements.paletteScopeBadge, + Object.keys(state.palette).length ? "Custom · carries" : "Theme", + Object.keys(state.palette).length ? "custom" : "carry", + ); + setScopeBadge( + elements.motionScopeBadge, + motionCustom ? "Custom" : "Recipe", + motionCustom ? "custom" : "recipe", + ); + setScopeBadge( + elements.overrideSummary, + changes ? `${changes} custom ${changes === 1 ? "change" : "changes"}` : "Recipe base", + changes ? "custom" : "recipe", + ); + if (elements.resetOverridesButton) { + elements.resetOverridesButton.disabled = changes === 0; + } + if (elements.undoStudioButton) { + elements.undoStudioButton.disabled = undoStack.length === 0; + const latest = undoStack.at(-1); + elements.undoStudioButton.title = latest + ? `Undo: ${latest.label}` + : "Nothing to undo"; + } +} + +function pushUndoSnapshot(label) { + if (isRestoringStudioState) return; + undoStack.push({ label, state: cloneData(captureStudioState()) }); + if (undoStack.length > 24) undoStack.shift(); + updateWorkflowState(); +} + +function beginMutation(key, label) { + if (activeMutations.has(key)) return; + activeMutations.add(key); + pushUndoSnapshot(label); +} + +function endMutation(key) { + activeMutations.delete(key); +} + +function syncStudioInputs(state) { + if (elements.fieldSelect) { + elements.fieldSelect.value = state.fieldOverride || "__recipe__"; + } + if (elements.transitionSelect) { + elements.transitionSelect.value = + transitionOptionName(state.transitionOverride) || "__recipe__"; + } + if (elements.pixelShapeSelect && state.pixelShape) { + elements.pixelShapeSelect.value = state.pixelShape; + } + if (elements.pixelSwitchSelect) { + const recipe = spriteById.get(state.sprite); + elements.pixelSwitchSelect.value = state.pixelSwitchOverridden + ? pixelSwitchOptionMode(state.pixelSwitch) + : recipePixelSwitch(recipe); + } + if (elements.orbBoundaryInput) { + elements.orbBoundaryInput.checked = state.orbBoundary === "gestalt"; + } + orbBackgroundColorChoice = + state.orbBackgroundColorChoice || + state.orbBackgroundColor || + orbBackgroundColorChoice; + orbBackgroundModeChoice = normalizedOrbBackgroundMode( + state.orbBackgroundModeChoice, + state.orbBackgroundColorChoice, + ); + if (orbBackgroundModeChoice === "none") orbBackgroundModeChoice = "solid"; + setOrbBackgroundControlState( + state.orbBackgroundColor, + supportsGestaltBoundary(spriteById.get(state.sprite)), + state.orbBackgroundMode, + ); + if (elements.resolutionRange) elements.resolutionRange.value = String(state.gridSize); + if (elements.resolutionValue) { + elements.resolutionValue.textContent = `${state.gridSize}×${state.gridSize}`; + } + if (elements.speedRange) elements.speedRange.value = String(state.speed); + if (elements.speedValue) elements.speedValue.textContent = `${formatNumber(state.speed)}×`; + if (elements.densityRange) elements.densityRange.value = String(state.density); + if (elements.densityValue) { + elements.densityValue.textContent = formatNumber(state.density); + } + if (elements.seedInput) elements.seedInput.value = state.seed; + if (elements.progressRange) { + elements.progressRange.value = String(Math.round(state.progress * 100)); + } + if (elements.progressValue) { + elements.progressValue.textContent = `${Math.round(state.progress * 100)}%`; + } +} + +function restoreStudioState(state, { announce = null } = {}) { + if (!state || !spriteById.has(state.sprite)) return; + isRestoringStudioState = true; + try { + stopAutopilot(); + cancelPendingResolutionCommit(); + pendingResolution = state.gridSize; + activeMutations.clear(); + transitionOverrideValue = normalizeTransitionOption(state.transitionOverride); + transitionOverridden = Boolean(transitionOverrideValue); + pixelShapeOverridden = Boolean(state.pixelShapeOverridden); + const pixelSwitchOverride = normalizePixelSwitchOption(state.pixelSwitch); + pixelSwitchOverridden = Boolean( + state.pixelSwitchOverridden && pixelSwitchOverride, + ); + customNonErrorPalette = { ...(state.palette || {}) }; + savedGlowOverride = state.savedGlowOverride ?? null; + hadSavedGlowOverride = Boolean(state.hadSavedGlowOverride); + orbBackgroundColorChoice = + state.orbBackgroundColorChoice || + state.orbBackgroundColor || + orbBackgroundColorChoice; + orbBackgroundModeChoice = normalizedOrbBackgroundMode( + state.orbBackgroundModeChoice, + state.orbBackgroundColorChoice, + ); + if (orbBackgroundModeChoice === "none") orbBackgroundModeChoice = "solid"; + + engine.setOptions({ + pixelShape: pixelShapeOverridden ? state.pixelShape : null, + pixelSwitch: pixelSwitchOverridden ? pixelSwitchOverride : null, + transition: transitionOverrideValue, + speed: state.speed, + density: state.density, + orbBoundary: state.orbBoundary === "gestalt" ? "gestalt" : "defined", + orbBackgroundColor: + normalizedOrbBackgroundColor(state.orbBackgroundColor), + orbBackgroundMode: normalizedOrbBackgroundMode( + state.orbBackgroundMode, + state.orbBackgroundColor, + ), + }); + engine.setResolution(state.gridSize); + engine.setSeed(state.seed); + engine.setSprite(state.sprite, { + transition: "instant", + immediate: true, + preservePhase: true, + useRecipeFields: !state.fieldOverride, + }); + if (state.fieldOverride) engine.setField(state.fieldOverride); + engine.setProgress(state.progress); + syncStudioInputs({ + ...state, + transitionOverride: transitionOverrideValue, + pixelSwitch: pixelSwitchOverride, + pixelSwitchOverridden, + }); + applyPreviewMode(state.previewMode || previewMode); + updateStateUI(spriteById.get(state.sprite)); + schedulePreviewResolutionSync(state.gridSize); + applyStudioPalette(); + syncOrbAppearanceToPreviews(); + buildStatePicker(elements.stateSearchInput?.value || ""); + updateWorkflowState(); + updateCode(); + } finally { + isRestoringStudioState = false; + } + if (announce) showWorkflowStatus(announce, { tone: "success" }); +} + +function resetStudioOverrides() { + if (!baselineStudioState) return; + pushUndoSnapshot("reset custom changes"); + const reset = { + ...cloneData(baselineStudioState), + sprite: selectedSpriteId, + previewMode, + }; + restoreStudioState(reset, { announce: "Custom changes reset to the recipe base." }); +} + +function undoStudioChange() { + const entry = undoStack.pop(); + if (!entry) return; + restoreStudioState(entry.state, { announce: `Undid ${entry.label}.` }); + updateWorkflowState(); +} + +function portableRecipe() { + if (!builtInSpriteIds.has(engine.currentRecipe.id)) { + throw new Error( + "Imported artwork is not embedded in recipe JSON. Export an image, or reopen the artwork separately.", + ); + } + const config = engine.exportConfig(); + const transitionOverride = transitionOverridden + ? normalizeTransitionOption(transitionOverrideValue) + : null; + const pixelSwitchOverride = pixelSwitchOverridden + ? normalizePixelSwitchOption(engine.options.pixelSwitch) + : null; + return { + ...config, + transition: transitionOverride || config.transition, + pixelSwitch: pixelSwitchOverride || config.pixelSwitch, + package: STUDIO_PACKAGE, + version: STUDIO_VERSION, + studio: { + schema: 1, + fieldOverride: engine.fieldOverride || null, + transitionOverride, + pixelShapeCarries: true, + pixelSwitchOverridden, + pixelSwitchOverride, + orbBackgroundColorChoice, + orbBackgroundModeChoice, + customNonErrorPalette: { ...customNonErrorPalette }, + }, + }; +} + +function normalizePalette(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const palette = {}; + for (const key of ["background", "off", "ink", "accent", "glow"]) { + const color = value[key]; + if (typeof color === "string" && SAFE_COLOR.test(color.trim())) { + palette[key] = color.trim(); + } + } + return palette; +} + +function normalizeImportedRecipe(source) { + if (!source || typeof source !== "object" || Array.isArray(source)) { + throw new TypeError("Recipe JSON must contain one object."); + } + if (source.package && source.package !== STUDIO_PACKAGE) { + throw new TypeError(`This file targets ${source.package}, not Orby.`); + } + if (typeof source.version !== "string") { + throw new TypeError("Recipe version is missing."); + } + const major = Number(source.version.split(".")[0]); + if (major !== 5) { + throw new RangeError(`Recipe version ${source.version} is not compatible with v5.`); + } + if (!spriteById.has(source.sprite)) { + throw new RangeError(`Unknown recipe state: ${source.sprite || "missing"}.`); + } + + const numberInRange = (value, fallback, minimum, maximum) => { + const number = Number(value); + return Number.isFinite(number) + ? Math.min(maximum, Math.max(minimum, number)) + : fallback; + }; + const studio = source.studio && typeof source.studio === "object" ? source.studio : {}; + const fieldOverride = + source.fieldMode === "override" && FIELD_IDS.includes(source.field) + ? source.field + : FIELD_IDS.includes(studio.fieldOverride) + ? studio.fieldOverride + : null; + const transitionOverride = normalizeTransitionOption( + Object.hasOwn(studio, "transitionOverride") + ? studio.transitionOverride + : source.transition, + ); + const pixelShape = PIXEL_SHAPES.includes(source.pixelShape) + ? source.pixelShape + : baselineStudioState?.pixelShape || "disc"; + const pixelShapeOverridden = Object.hasOwn(studio, "pixelShapeCarries") + ? Boolean(studio.pixelShapeCarries && PIXEL_SHAPES.includes(source.pixelShape)) + : PIXEL_SHAPES.includes(source.pixelShape); + const pixelSwitch = normalizePixelSwitchOption( + Object.hasOwn(studio, "pixelSwitchOverride") + ? studio.pixelSwitchOverride + : source.pixelSwitch, + ); + const paletteSource = studio.customNonErrorPalette || source.nonErrorPalette; + const importedOrbBackgroundColor = + typeof source.orbBackgroundColor === "string" && + SAFE_COLOR.test(source.orbBackgroundColor.trim()) && + source.orbBackgroundColor.trim().toLowerCase() !== "transparent" + ? source.orbBackgroundColor.trim() + : null; + const importedOrbBackgroundMode = ["none", "solid", "pixelated"].includes( + source.orbBackgroundMode, + ) + ? source.orbBackgroundMode + : importedOrbBackgroundColor + ? "solid" + : "none"; + const importedOrbBackgroundColorChoice = + typeof studio.orbBackgroundColorChoice === "string" && + SAFE_COLOR.test(studio.orbBackgroundColorChoice.trim()) + ? studio.orbBackgroundColorChoice.trim() + : importedOrbBackgroundColor || "#262626"; + const importedOrbBackgroundModeChoice = + studio.orbBackgroundModeChoice === "pixelated" || + studio.orbBackgroundModeChoice === "solid" + ? studio.orbBackgroundModeChoice + : importedOrbBackgroundMode === "pixelated" + ? "pixelated" + : "solid"; + + return { + package: STUDIO_PACKAGE, + version: source.version, + sprite: source.sprite, + seed: String(source.seed || "joan-v5").slice(0, 160), + gridSize: Math.round(numberInRange(source.gridSize, 68, 12, 96)), + speed: numberInRange(source.speed, 1, 0.1, 5), + density: numberInRange(source.density, 1, 0.35, 1.6), + progress: numberInRange(source.progress, 0, 0, 1), + fieldOverride, + transitionOverride, + pixelShape, + pixelShapeOverridden, + pixelSwitch, + pixelSwitchOverridden: Object.hasOwn(studio, "pixelSwitchOverridden") + ? Boolean(studio.pixelSwitchOverridden && pixelSwitch) + : Boolean(pixelSwitch), + orbBoundary: source.orbBoundary === "gestalt" ? "gestalt" : "defined", + orbBackgroundColor: importedOrbBackgroundColor, + orbBackgroundMode: importedOrbBackgroundMode, + orbBackgroundColorChoice: importedOrbBackgroundColorChoice, + orbBackgroundModeChoice: importedOrbBackgroundModeChoice, + palette: normalizePalette(paletteSource), + savedGlowOverride: null, + hadSavedGlowOverride: false, + previewMode, + }; +} + +function presetCreatedAtFromId(id) { + const match = /^preset-(\d{10,})-/.exec(String(id || "")); + const timestamp = Number(match?.[1]); + if (!Number.isFinite(timestamp)) return null; + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function normalizeStoredPreset(item, index = 0) { + if (!item || typeof item !== "object" || !item.recipe) return null; + const id = + typeof item.id === "string" && item.id.trim() + ? item.id.trim().slice(0, 160) + : `preset-migrated-${index}`; + const candidateDate = + typeof item.createdAt === "string" ? new Date(item.createdAt) : null; + const createdAt = + candidateDate && !Number.isNaN(candidateDate.getTime()) + ? candidateDate.toISOString() + : presetCreatedAtFromId(id); + return { + id, + label: String(item.label || "Saved preset").slice(0, 72), + createdAt, + recipe: item.recipe, + }; +} + +function presetStateLabel(preset) { + const recipe = spriteById.get(preset?.recipe?.sprite); + return recipe ? recipeLabel(recipe) : humanize(preset?.recipe?.sprite || "Saved state"); +} + +function presetTitle(preset) { + return String(preset?.label || presetStateLabel(preset)).trim(); +} + +function presetDateLabel(preset) { + if (!preset?.createdAt) return "Saved locally"; + const date = new Date(preset.createdAt); + if (Number.isNaN(date.getTime())) return "Saved locally"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function paintSavedPresetPreview(canvas, preset) { + const spriteId = preset?.recipe?.sprite; + if (!canvas || !spriteById.has(spriteId)) return; + const previewKey = preset.id; + const queued = savedPresetPreviewQueue.get(previewKey); + if (queued) { + queued.push(canvas); + return; + } + const canvases = [canvas]; + savedPresetPreviewQueue.set(previewKey, canvases); + enqueuePreviewWork(() => { + savedPresetPreviewQueue.delete(previewKey); + if (previewsDisposed) return; + let preview = null; + try { + const state = normalizeImportedRecipe(preset.recipe); + const source = document.createElement("canvas"); + source.width = 50; + source.height = 50; + const options = { + sprite: state.sprite, + seed: state.seed, + gridSize: state.gridSize, + speed: state.speed, + density: state.density, + progress: state.progress, + pixelShape: state.pixelShapeOverridden ? state.pixelShape : null, + pixelSwitch: state.pixelSwitchOverridden ? state.pixelSwitch : null, + transition: null, + orbBoundary: state.orbBoundary, + orbBackgroundColor: state.orbBackgroundColor, + orbBackgroundMode: state.orbBackgroundMode, + nonErrorPalette: { + ...studioPaletteOverride(), + ...state.palette, + }, + autoplay: false, + autoResize: false, + interactive: false, + background: true, + offPixels: false, + quality: "low", + dprMax: 1, + reducedMotion: true, + }; + if (state.fieldOverride) options.field = state.fieldOverride; + preview = new JoanGlyphEngine(source, options); + renderThumbnailRestFrame(preview, { progress: state.progress }); + for (const target of canvases) { + if (!target.isConnected) continue; + const context = target.getContext("2d"); + if (!context) continue; + context.clearRect(0, 0, target.width, target.height); + context.imageSmoothingEnabled = false; + context.drawImage(source, 0, 0, target.width, target.height); + target.dataset.previewState = "ready"; + } + } catch { + // A legacy preset that no longer validates still gets the authored state + // thumbnail instead of leaving a permanently empty tile. + const fallback = ensurePreviewEngine(spriteId); + const source = previewDescriptors.get(spriteId)?.canvas; + if (!fallback || !source) return; + if (!previewShouldAnimate(spriteId)) freezePreview(fallback); + for (const target of canvases) { + if (!target.isConnected) continue; + const context = target.getContext("2d"); + if (!context) continue; + context.clearRect(0, 0, target.width, target.height); + context.imageSmoothingEnabled = false; + context.drawImage(source, 0, 0, target.width, target.height); + target.dataset.previewState = "ready"; + } + } finally { + preview?.destroy(); + } + }); +} + +function observeSavedPresetPreview(canvas, preset) { + if (typeof IntersectionObserver !== "function") { + paintSavedPresetPreview(canvas, preset); + return; + } + if (!savedPresetPreviewObserver) { + savedPresetPreviewObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + savedPresetPreviewObserver.unobserve(entry.target); + const current = recentPresets.find( + (item) => item.id === entry.target.dataset.presetId, + ); + if (current) paintSavedPresetPreview(entry.target, current); + } + }, + { + root: elements.savedPresetGallery, + rootMargin: "120px 0px", + threshold: 0.01, + }, + ); + } + canvas.dataset.presetId = preset.id; + savedPresetPreviewObserver.observe(canvas); +} + +function renderRecentPresets() { + const hasPresets = recentPresets.length > 0; + const select = elements.recentPresetSelect; + if (select) { + select.textContent = ""; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = hasPresets ? "Quick open preset" : "No saved presets"; + select.append(placeholder); + for (const preset of recentPresets) { + const option = document.createElement("option"); + option.value = preset.id; + option.textContent = `${presetTitle(preset)} · ${presetDateLabel(preset)}`; + select.append(option); + } + select.disabled = !hasPresets; + } + + if (elements.savedPresetCount) { + const count = recentPresets.length; + elements.savedPresetCount.textContent = `${count} preset${count === 1 ? "" : "s"}`; + } + if (elements.savedPresetEmpty) elements.savedPresetEmpty.hidden = hasPresets; + if (!elements.savedPresetGallery) return; + + savedPresetPreviewObserver?.disconnect(); + elements.savedPresetGallery.textContent = ""; + elements.savedPresetGallery.hidden = !hasPresets; + if (!hasPresets) return; + + const fragment = document.createDocumentFragment(); + const previews = []; + for (const preset of recentPresets) { + const stateLabel = presetStateLabel(preset); + const titleLabel = presetTitle(preset); + const accessibleIdentity = + titleLabel === stateLabel ? stateLabel : `${titleLabel}, ${stateLabel}`; + const dateLabel = presetDateLabel(preset); + const savedDescription = preset.createdAt ? `saved ${dateLabel}` : dateLabel; + const resolution = Math.round(Number(preset.recipe?.gridSize) || 68); + const item = document.createElement("article"); + item.className = "saved-preset-card"; + item.dataset.presetId = preset.id; + item.setAttribute("role", "listitem"); + + const openButton = document.createElement("button"); + openButton.type = "button"; + openButton.className = "saved-preset-card__open"; + openButton.dataset.openPreset = preset.id; + openButton.setAttribute( + "aria-label", + `Open ${accessibleIdentity}, ${savedDescription}`, + ); + + const previewWrap = document.createElement("span"); + previewWrap.className = "saved-preset-card__preview"; + const canvas = document.createElement("canvas"); + canvas.width = 50; + canvas.height = 50; + canvas.dataset.previewState = "pending"; + canvas.setAttribute("aria-hidden", "true"); + previewWrap.append(canvas); + + const copy = document.createElement("span"); + copy.className = "saved-preset-card__copy"; + const label = document.createElement("strong"); + label.textContent = titleLabel; + const state = document.createElement("small"); + state.textContent = + titleLabel === stateLabel + ? preset.recipe?.sprite || stateLabel + : `${stateLabel} · ${preset.recipe?.sprite || "custom"}`; + const metadata = document.createElement("span"); + metadata.className = "saved-preset-card__metadata"; + metadata.textContent = `${resolution}×${resolution} · ${dateLabel}`; + copy.append(label, state, metadata); + openButton.append(previewWrap, copy); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "saved-preset-card__delete"; + deleteButton.dataset.deletePreset = preset.id; + deleteButton.textContent = "Delete"; + deleteButton.setAttribute( + "aria-label", + `Delete ${accessibleIdentity}, ${savedDescription}`, + ); + + item.append(openButton, deleteButton); + fragment.append(item); + previews.push([canvas, preset]); + } + elements.savedPresetGallery.append(fragment); + for (const [canvas, preset] of previews) observeSavedPresetPreview(canvas, preset); +} + +function persistRecentPresets() { + try { + localStorage.setItem(RECENT_PRESET_KEY, JSON.stringify(recentPresets)); + } catch { + showWorkflowStatus("Preset changes apply to this session only.", { + tone: "warning", + }); + } + renderRecentPresets(); +} + +function loadRecentPresets() { + let stored = []; + try { + const parsed = JSON.parse(localStorage.getItem(RECENT_PRESET_KEY) || "[]"); + stored = Array.isArray(parsed) ? parsed : []; + } catch { + stored = []; + } + recentPresets = stored + .map((item, index) => normalizeStoredPreset(item, index)) + .filter(Boolean) + .slice(0, MAX_RECENT_PRESETS); + if ( + JSON.stringify(stored.slice(0, MAX_RECENT_PRESETS)) !== + JSON.stringify(recentPresets) + ) { + try { + localStorage.setItem(RECENT_PRESET_KEY, JSON.stringify(recentPresets)); + } catch { + // Keep migrated presets available for this session when storage is read-only. + } + } + renderRecentPresets(); +} + +function rememberPreset(recipe, label) { + const createdAt = new Date().toISOString(); + const id = `preset-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + recentPresets = [ + { + id, + label: String(label || "Saved preset").slice(0, 72), + createdAt, + recipe, + }, + ...recentPresets, + ].slice(0, MAX_RECENT_PRESETS); + persistRecentPresets(); + return id; +} + +function openRecentPreset(id) { + const preset = recentPresets.find((item) => item.id === id); + if (!preset) return; + try { + applyPortableRecipe(preset.recipe, { label: preset.label }); + } catch (error) { + showWorkflowStatus(error?.message || "The saved preset could not be opened.", { + tone: "danger", + }); + } +} + +function deleteRecentPreset(id) { + const index = recentPresets.findIndex((item) => item.id === id); + if (index < 0) return; + const [preset] = recentPresets.splice(index, 1); + deletedPreset = { preset, index }; + persistRecentPresets(); + if (elements.deletedPresetMessage) { + elements.deletedPresetMessage.textContent = `${presetTitle(preset)} deleted.`; + } + if (elements.deletedPresetNotice) elements.deletedPresetNotice.hidden = false; + showWorkflowStatus(`${presetTitle(preset)} deleted. Undo is available below.`, { + tone: "warning", + }); + window.requestAnimationFrame(() => elements.undoDeletePresetButton?.focus()); +} + +function undoDeletedPreset() { + if (!deletedPreset) return; + const { preset, index } = deletedPreset; + recentPresets.splice(Math.min(index, recentPresets.length), 0, preset); + recentPresets = recentPresets.slice(0, MAX_RECENT_PRESETS); + deletedPreset = null; + persistRecentPresets(); + if (elements.deletedPresetNotice) elements.deletedPresetNotice.hidden = true; + showWorkflowStatus(`${presetTitle(preset)} restored.`, { tone: "success" }); + window.requestAnimationFrame(() => { + const restoredButton = [ + ...(elements.savedPresetGallery?.querySelectorAll("[data-open-preset]") || []), + ].find((button) => button.dataset.openPreset === preset.id); + restoredButton?.focus(); + }); +} + +function saveCurrentPreset() { + let recipe; + try { + recipe = portableRecipe(); + } catch (error) { + showWorkflowStatus(error.message, { tone: "danger" }); + return; + } + const date = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date()); + const label = recipeLabel(engine.currentRecipe); + rememberPreset(recipe, label); + showWorkflowStatus(`Saved ${label} · ${date} to recent presets.`, { + tone: "success", + }); +} + +function applyPortableRecipe(source, { label = "recipe", remember = false } = {}) { + const normalized = normalizeImportedRecipe(source); + pushUndoSnapshot(`open ${label}`); + restoreStudioState(normalized, { + announce: `Opened ${label}. ${recipeLabel(spriteById.get(normalized.sprite))} is on stage.`, + }); + if (remember) rememberPreset(source, label); +} + +async function openRecipeFile(file) { + if (!file) return; + if (file.size > 1024 * 1024) { + throw new RangeError("Recipe files must be smaller than 1 MB."); + } + const source = JSON.parse(await file.text()); + const label = file.name.replace(/\.joan\.json$|\.json$/i, "") || "recipe"; + applyPortableRecipe(source, { label, remember: true }); +} + +function updateCode() { + if (!elements.codeSnippet) return; + const config = engine.exportConfig(); + const paletteLine = config.nonErrorPalette + ? `\n nonErrorPalette: ${formatCodeValue(config.nonErrorPalette, " ")},` + : ""; + const fieldLine = + config.fieldMode === "override" ? `\n field: "${config.field}",` : ""; + const transitionValue = transitionOverridden + ? normalizeTransitionOption(transitionOverrideValue) + : null; + const transitionLine = transitionValue + ? `\n transition: ${formatCodeValue(transitionValue)},` + : ""; + elements.codeSnippet.textContent = `import { createGlyph } from "@joan/procedural-glyph-engine"; +import { playStateSequence } from "@joan/procedural-glyph-engine/state-sequence"; + +const glyph = createGlyph("#ai-glyph", { + sprite: "${config.sprite}", + seed: "${config.seed.replaceAll('"', '\\"')}", + gridSize: ${config.gridSize}, + speed: ${formatNumber(config.speed)}, + density: ${formatNumber(config.density)},${paletteLine}${fieldLine}${transitionLine} + orbBoundary: "${config.orbBoundary}", + orbBackgroundColor: ${JSON.stringify(config.orbBackgroundColor)}, + orbBackgroundMode: "${config.orbBackgroundMode}", + pixelShape: "${config.pixelShape}", + pixelSwitch: ${formatCodeValue(config.pixelSwitch)}, + interactive: true +}); + +const playback = playStateSequence(glyph, [ + { sprite: "${config.sprite}", holdMs: 5_000 }, + { sprite: "status.success", transition: "path-draw" } +]); + +await playback.finished;`; +} + +function updateStateUI(recipe) { + if (!recipe) return; + const label = recipeLabel(recipe); + const category = recipe.semantic?.category || recipe.category || "semantic"; + const previousSelectedId = selectedSpriteId; + selectedSpriteId = recipe.id; + ensurePreviewEngine(recipe.id); + if (previousSelectedId !== selectedSpriteId) { + freezePreviewIfInactive(previousSelectedId); + } + if (elements.stateTitle) elements.stateTitle.textContent = label; + if (elements.stateDescription) { + elements.stateDescription.textContent = recipeDescription(recipe); + } + if (elements.stateCategory) elements.stateCategory.textContent = humanize(category); + if (elements.currentStateValue) elements.currentStateValue.textContent = recipe.id; + const libraryHasSelection = spriteButtons.has(recipe.id); + for (const [id, button] of spriteButtons) { + const selected = id === recipe.id; + button.classList.toggle("is-selected", selected); + button.setAttribute("aria-checked", String(selected)); + button.tabIndex = selected || (!libraryHasSelection && id === sprites[0]?.id) ? 0 : -1; + } + for (const button of document.querySelectorAll("[data-quick-sprite]")) { + const selected = button.dataset.quickSprite === recipe.id; + button.classList.toggle("is-selected", selected); + button.setAttribute("aria-pressed", String(selected)); + } + + if (elements.fieldSelect) { + elements.fieldSelect.value = engine.fieldOverride || "__recipe__"; + } + if (elements.transitionSelect && !transitionOverridden) { + elements.transitionSelect.value = "__recipe__"; + } + if (elements.pixelShapeSelect && !pixelShapeOverridden) { + const shapeAliases = { circle: "disc", "rounded-square": "square" }; + const sourceShape = + recipe.pixelShape || recipe.render?.pixelShape || recipe.pixel?.shape; + const shape = shapeAliases[sourceShape] || sourceShape; + if (PIXEL_SHAPES.includes(shape)) elements.pixelShapeSelect.value = shape; + } + if (elements.pixelSwitchSelect && !pixelSwitchOverridden) { + const pixelSwitch = recipePixelSwitch(recipe); + if (PIXEL_SWITCHES.includes(pixelSwitch)) { + elements.pixelSwitchSelect.value = pixelSwitch; + } + } + updateControlTip(elements.fieldSelect); + updateControlTip(elements.transitionSelect); + updateControlTip(elements.pixelShapeSelect); + updateControlTip(elements.pixelSwitchSelect); + + const boundaryAvailable = supportsGestaltBoundary(recipe); + if (elements.orbBoundaryControl) { + elements.orbBoundaryControl.dataset.available = String(boundaryAvailable); + } + if (elements.orbBoundaryInput) { + elements.orbBoundaryInput.disabled = !boundaryAvailable; + elements.orbBoundaryInput.checked = + engine.options.orbBoundary === "gestalt"; + } + if (elements.orbBoundaryAvailability) { + elements.orbBoundaryAvailability.textContent = boundaryAvailable + ? engine.options.orbBoundary === "gestalt" + ? "Circle implied by clustered edge pixels" + : "Continuous authored rim" + : "Available for Ready and ambient orbs · choice stays saved"; + } + updateControlTip(elements.orbBoundaryInput); + setOrbBackgroundControlState( + engine.options.orbBackgroundColor, + boundaryAvailable, + ); + + const isErrorState = recipe.id === "status.error"; + elements.nonErrorPaletteSection?.classList.toggle( + "is-error-state", + isErrorState, + ); + if (!isErrorState) updatePaletteControls(engine.exportConfig().palette); + + const supportsProgress = + recipe.id === "ai.progress" || recipe.id === "transfer.active"; + elements.progressControl?.classList.toggle("is-visible", supportsProgress); + elements.progressControl?.setAttribute("aria-hidden", String(!supportsProgress)); + if (elements.statePickerSelect) elements.statePickerSelect.value = recipe.id; + buildStatePicker(elements.stateSearchInput?.value || ""); + updateWorkflowState(); + updateCode(); +} + +function selectSprite(id, options = {}) { + const recipe = spriteById.get(id); + if (!recipe) return; + if (options.recordUndo !== false && recipe.id !== selectedSpriteId) { + pushUndoSnapshot(`select ${recipeLabel(recipe)}`); + } + engine.setSprite(id, { + transition: + options.transition || + (transitionOverridden ? transitionOverrideValue : undefined), + duration: options.duration, + preservePhase: options.preservePhase, + }); + updateStateUI(recipe); +} + +function ensurePreviewEngine(id) { + if (previewsDisposed) return null; + const existing = previewById.get(id); + if (existing) { + previewCardObserver?.unobserve(spriteButtons.get(id)); + return existing; + } + const descriptor = previewDescriptors.get(id); + if (!descriptor) return null; + const { canvas, index, recipe } = descriptor; + const preview = new JoanGlyphEngine(canvas, { + sprite: createThumbnailRecipe(recipe), + seed: `preview-${index}`, + gridSize: engine.gridSize, + progress: currentPreviewProgress(), + autoplay: false, + autoResize: false, + interactive: false, + background: true, + offPixels: false, + quality: "low", + dprMax: 1, + fps: 12, + // The constructor paints the authored representative pose once. Restore + // the user's motion preference immediately afterward for active cards. + reducedMotion: true, + nonErrorPalette: studioPaletteOverride(), + orbBoundary: engine.options.orbBoundary, + orbBackgroundColor: engine.options.orbBackgroundColor, + orbBackgroundMode: engine.options.orbBackgroundMode, + }); + preview.previewSpriteId = recipe.id; + preview.reducedMotion = reduceMotion.matches; + previewEngines.push(preview); + previewById.set(recipe.id, preview); + previewCardObserver?.unobserve(spriteButtons.get(recipe.id)); + setPreviewResolution(preview, engine.gridSize); + if (preview.canvas?.dataset) preview.canvas.dataset.previewState = "ready"; + return preview; +} + +function scheduleDeferredPreviewConstruction() { + // IntersectionObserver constructs only cards approaching the viewport. The + // idle sweep remains as a compatibility fallback for older embedded hosts. + if (typeof IntersectionObserver === "function") return; + const pendingIds = sprites + .map((recipe) => recipe.id) + .filter((id) => id !== selectedSpriteId); + const buildNext = (deadline) => { + if (previewsDisposed) return; + const minimumBatch = 1; + const maximumBatch = 1; + let built = 0; + + while (pendingIds.length && built < maximumBatch) { + const id = pendingIds.shift(); + if (!id || previewById.has(id)) continue; + ensurePreviewEngine(id); + built += 1; + + const remaining = + typeof deadline?.timeRemaining === "function" + ? deadline.timeRemaining() + : 0; + if (built >= minimumBatch && remaining < 6) break; + } + + if (pendingIds.length) enqueuePreviewWork(buildNext); + }; + if (pendingIds.length) enqueuePreviewWork(buildNext); +} + +function flushUrgentPreviewConstruction() { + urgentPreviewScheduled = false; + const id = urgentPreviewIds.values().next().value; + if (id) urgentPreviewIds.delete(id); + if (id && !previewById.has(id)) ensurePreviewEngine(id); + if (urgentPreviewIds.size && !previewsDisposed) { + scheduleUrgentPreviewConstruction(); + } +} + +function scheduleUrgentPreviewConstruction() { + if (urgentPreviewScheduled || !urgentPreviewIds.size || previewsDisposed) return; + urgentPreviewScheduled = true; + enqueuePreviewWork(flushUrgentPreviewConstruction, { priority: true }); +} + +function prioritizePreviewConstruction(ids) { + for (const id of ids) { + if (id && !previewById.has(id)) urgentPreviewIds.add(id); + } + scheduleUrgentPreviewConstruction(); +} + +function startPreviewCardObserver() { + if (typeof IntersectionObserver !== "function") return; + previewCardObserver = new IntersectionObserver( + (entries) => { + const visibleIds = []; + for (const entry of entries) { + const id = entry.target.dataset.sprite; + if (entry.isIntersecting) visibleIds.push(id); + else urgentPreviewIds.delete(id); + } + prioritizePreviewConstruction(visibleIds); + }, + { rootMargin: "600px 0px", threshold: 0.01 }, + ); + for (const [id, button] of spriteButtons) { + if (!previewById.has(id)) previewCardObserver.observe(button); + } +} + +function buildSpriteLibrary() { + if (!elements.spriteGrid) return; + const fragment = document.createDocumentFragment(); + sprites.forEach((recipe, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "sprite-card"; + button.dataset.sprite = recipe.id; + button.setAttribute("role", "radio"); + button.setAttribute("aria-checked", String(recipe.id === selectedSpriteId)); + button.tabIndex = recipe.id === selectedSpriteId ? 0 : -1; + button.setAttribute( + "aria-label", + `${ + recipe.labels?.default || + recipe.semantic?.label || + recipe.label || + humanize(recipe.id) + }: ${recipeDescription(recipe)}`, + ); + + const previewWrap = document.createElement("span"); + previewWrap.className = "sprite-preview"; + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + canvas.dataset.previewState = "pending"; + canvas.setAttribute("aria-hidden", "true"); + previewWrap.append(canvas); + + const copy = document.createElement("span"); + copy.className = "sprite-copy"; + const label = document.createElement("strong"); + label.textContent = + recipe.labels?.default || + recipe.semantic?.label || + recipe.label || + humanize(recipe.id); + const idLabel = document.createElement("small"); + idLabel.textContent = recipe.id; + copy.append(label, idLabel); + button.append(previewWrap, copy); + button.addEventListener("pointerenter", () => { + hoveredPreviewIds.add(recipe.id); + ensurePreviewEngine(recipe.id); + }); + button.addEventListener("pointerleave", () => { + hoveredPreviewIds.delete(recipe.id); + freezePreviewIfInactive(recipe.id); + }); + button.addEventListener("focus", () => { + if (!button.matches(":focus-visible")) return; + focusedPreviewIds.add(recipe.id); + ensurePreviewEngine(recipe.id); + }); + button.addEventListener("blur", () => { + focusedPreviewIds.delete(recipe.id); + freezePreviewIfInactive(recipe.id); + }); + button.addEventListener("click", () => selectSprite(recipe.id)); + button.addEventListener("keydown", (event) => { + const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]; + if (!keys.includes(event.key)) return; + event.preventDefault(); + const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1; + let nextIndex = index; + if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = sprites.length - 1; + else nextIndex = (index + direction + sprites.length) % sprites.length; + const next = spriteButtons.get(sprites[nextIndex].id); + selectSprite(sprites[nextIndex].id); + next?.focus(); + }); + fragment.append(button); + spriteButtons.set(recipe.id, button); + previewDescriptors.set(recipe.id, { canvas, index, recipe }); + }); + elements.spriteGrid.append(fragment); + ensurePreviewEngine(selectedSpriteId); + startPreviewCardObserver(); + scheduleDeferredPreviewConstruction(); +} + +setTheme(isLight, { persist: false }); +heroEngine = elements.heroCanvas + ? new JoanGlyphEngine(elements.heroCanvas, { + sprite: "ai.idle", + seed: "world-class-ai", + gridSize: 68, + speed: 1, + density: 1, + interactive: false, + background: false, + offPixels: true, + fps: 30, + quality: "auto", + dprMax: 1.5, + nonErrorPalette: studioPaletteOverride(), + }) + : null; +buildSpriteLibrary(); +updateStateUI(engine.currentRecipe); +applyPreviewMode(previewMode); +const initialPixelShape = elements.pixelShapeSelect?.value; +if (PIXEL_SHAPES.includes(initialPixelShape)) { + engine.setPixelShape(initialPixelShape); + pixelShapeOverridden = true; +} +engine.setProgress((Number(elements.progressRange?.value) || 0) / 100); +baselineStudioState = cloneData(captureStudioState()); +buildStatePicker(); +loadRecentPresets(); +updateWorkflowState(); +updateCode(); + +let previewLast = 0; +function animatePreviews(timestamp) { + requestAnimationFrame(animatePreviews); + if (reduceMotion.matches || document.hidden || timestamp - previewLast < 90) return; + const previewDt = previewLast + ? Math.min(0.12, (timestamp - previewLast) / 1000) + : 1 / 12; + previewLast = timestamp; + for (const preview of previewEngines) { + const id = previewId(preview); + if (!preview.visible || !previewShouldAnimate(id)) continue; + if (preview.gridSize !== engine.gridSize) { + setPreviewResolution(preview, engine.gridSize); + } + preview.renderFrame(timestamp / 1000, previewDt); + } +} +requestAnimationFrame(animatePreviews); + +function updatePlayButton() { + if (!elements.playButton) return; + elements.playButton.textContent = engine.running ? "Pause motion" : "Play motion"; + elements.playButton.setAttribute("aria-pressed", String(engine.running)); +} +updatePlayButton(); + +function stopAutopilot() { + if (autopilotTimer !== null) window.clearInterval(autopilotTimer); + autopilotTimer = null; + elements.autopilotButton?.classList.remove("is-active"); + elements.autopilotButton?.setAttribute("aria-pressed", "false"); + if (elements.autopilotButton) { + elements.autopilotButton.textContent = "Auto-play AI states"; + elements.autopilotButton.title = + "Automatically cycles through 10 common AI states every 4 seconds. Click again to stop."; + } +} + +const story = [ + "ai.idle", + "ai.listening", + "ai.thinking", + "ai.thinking-deep", + "ai.still-working", + "ai.searching", + "ai.tool-use", + "ai.generating", + "ai.speaking", + "status.success", +]; + +function toggleAutopilot() { + if (autopilotTimer !== null) { + stopAutopilot(); + return; + } + autopilotIndex = Math.max(0, story.indexOf(selectedSpriteId)); + selectSprite(story[autopilotIndex], { duration: 0.5, recordUndo: false }); + autopilotTimer = window.setInterval(() => { + autopilotIndex = (autopilotIndex + 1) % story.length; + selectSprite(story[autopilotIndex], { duration: 0.5, recordUndo: false }); + if (story[autopilotIndex] === "ai.generating") { + engine.signal("token", { energy: 0.8 }); + } + if (story[autopilotIndex] === "status.success") { + engine.signal("complete", { energy: 1 }); + } + }, 4200); + elements.autopilotButton?.classList.add("is-active"); + elements.autopilotButton?.setAttribute("aria-pressed", "true"); + if (elements.autopilotButton) { + elements.autopilotButton.textContent = "Stop AI state auto-play"; + elements.autopilotButton.title = "Stop the automatic AI state sequence."; + } +} + +function setTheme(light, { persist = true } = {}) { + isLight = Boolean(light); + const theme = isLight ? "light" : "dark"; + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + applyStudioPalette({ immediatePreviewRefresh: true }); + if (elements.themeButton) { + const nextThemeLabel = isLight ? "Dark mode" : "Light mode"; + elements.themeButton.textContent = nextThemeLabel; + elements.themeButton.setAttribute( + "aria-label", + `Switch to ${nextThemeLabel}`, + ); + elements.themeButton.setAttribute("aria-checked", String(isLight)); + elements.themeButton.title = `Switch to ${nextThemeLabel}`; + } + const themeColor = document.querySelector('meta[name="theme-color"]'); + if (themeColor) themeColor.content = isLight ? "#eeece6" : "#050506"; + if (persist) { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // Storage can be unavailable in private or embedded contexts. + } + } + updateWorkflowState(); +} + +function randomSeed() { + pushUndoSnapshot("generate a new seed"); + const bytes = new Uint32Array(2); + crypto.getRandomValues(bytes); + const value = `joan-${bytes[0].toString(36)}-${bytes[1].toString(36)}`; + if (elements.seedInput) elements.seedInput.value = value; + engine.setSeed(value); + engine.signal("reseed", { energy: 0.6 }); + updateWorkflowState(); + updateCode(); +} + +async function copyCode() { + const code = elements.codeSnippet?.textContent || ""; + try { + await navigator.clipboard.writeText(code); + } catch { + const range = document.createRange(); + range.selectNodeContents(elements.codeSnippet); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + document.execCommand("copy"); + selection.removeAllRanges(); + } + const old = elements.copyCodeButton?.textContent; + if (elements.copyCodeButton) elements.copyCodeButton.textContent = "Copied"; + showWorkflowStatus("Integration code copied.", { tone: "success" }); + window.setTimeout(() => { + if (elements.copyCodeButton) elements.copyCodeButton.textContent = old || "Copy"; + }, 1400); +} + +function downloadJSON( + filename = `${engine.currentRecipe.id.replaceAll(".", "-")}.joan.json`, +) { + const data = JSON.stringify(portableRecipe(), null, 2); + const blob = new Blob([`${data}\n`], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + return blob; +} + +async function downloadStudioPNG( + filename = `${engine.currentRecipe.id.replaceAll(".", "-")}.png`, + { background = true } = {}, +) { + const previousMode = previewMode; + const previousBackground = engine.options.background; + + engine.setOptions({ previewMode: "fit", background }); + engine.paint(); + try { + return await engine.downloadPNG(filename); + } finally { + engine.setOptions({ + previewMode: previousMode, + background: previousBackground, + }); + engine.paint(); + } +} + +const EXPORT_FORMATS = Object.freeze({ + png: { + extension: ".png", + label: "PNG", + motion: "Current frame", + }, + svg: { + extension: ".svg", + label: "SVG", + motion: "Current frame", + }, + "animated-svg": { + extension: ".animated.svg", + label: "Animated SVG", + motion: "2.4 s · 12 fps · 48×48 max grid", + }, + config: { + extension: ".joan.json", + label: "Orby recipe", + motion: "Deterministic parameters", + }, +}); + +function exportBaseName() { + return engine.currentRecipe.id.replaceAll(".", "-"); +} + +function defaultExportFilename(format) { + return `${exportBaseName()}${EXPORT_FORMATS[format]?.extension || ".png"}`; +} + +function sanitizeExportFilename(value, format) { + const meta = EXPORT_FORMATS[format] || EXPORT_FORMATS.png; + let filename = String(value || "") + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/^\.+/, ""); + if (!filename) filename = defaultExportFilename(format); + const knownExtension = [...Object.values(EXPORT_FORMATS)] + .sort((left, right) => right.extension.length - left.extension.length) + .find(({ extension }) => filename.toLowerCase().endsWith(extension)); + if (knownExtension && knownExtension.extension !== meta.extension) { + filename = filename.slice(0, -knownExtension.extension.length); + } + if (!filename.toLowerCase().endsWith(meta.extension)) filename += meta.extension; + return filename.slice(0, 128); +} + +function renderExportPreview() { + const canvas = elements.exportPreviewCanvas; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + const includeBackground = elements.exportBackgroundInput?.checked !== false; + const previousMode = engine.options.previewMode; + const previousBackground = engine.options.background; + try { + engine.setOptions({ previewMode: "fit", background: includeBackground }); + context.clearRect(0, 0, canvas.width, canvas.height); + context.imageSmoothingEnabled = false; + context.drawImage(engine.canvas, 0, 0, canvas.width, canvas.height); + } finally { + engine.setOptions({ + previewMode: previousMode, + background: previousBackground, + }); + } + canvas.closest(".export-preview__frame")?.classList.toggle( + "is-transparent", + !includeBackground, + ); +} + +function updateExportPreflight({ resetFilename = false } = {}) { + const format = elements.exportFormatSelect?.value || "png"; + const meta = EXPORT_FORMATS[format] || EXPORT_FORMATS.png; + const vector = format === "svg" || format === "animated-svg"; + const recipe = format === "config"; + const size = Number(elements.exportSizeSelect?.value) || 512; + const includeBackground = elements.exportBackgroundInput?.checked !== false; + const recipePortable = builtInSpriteIds.has(engine.currentRecipe.id); + + if (elements.exportSizeSelect) elements.exportSizeSelect.disabled = !vector; + if (elements.exportBackgroundInput) elements.exportBackgroundInput.disabled = recipe; + if (elements.exportFilenameInput && resetFilename) { + elements.exportFilenameInput.value = defaultExportFilename(format); + } + if (elements.exportDimensionsValue) { + elements.exportDimensionsValue.textContent = recipe + ? "Portable JSON" + : format === "png" + ? `${engine.canvas.width}×${engine.canvas.height} px` + : `${size}×${size} px`; + } + if (elements.exportCompositionValue) { + elements.exportCompositionValue.textContent = recipe + ? "Parameters only" + : "Fit · full frame"; + } + if (elements.exportMotionValue) elements.exportMotionValue.textContent = meta.motion; + if (elements.exportBackgroundValue) { + elements.exportBackgroundValue.textContent = recipe + ? "Stored in palette" + : includeBackground + ? "Included" + : "Transparent"; + } + if (elements.exportPreviewNote) { + elements.exportPreviewNote.textContent = recipe + ? recipePortable + ? "The recipe stores deterministic controls and can be reopened in this studio." + : "Imported artwork is not embedded in JSON. Export an image or reopen the artwork separately." + : "Export uses the fitted full-frame composition. Fit and 1:1 only change the inspection view."; + } + if (elements.confirmExportButton) { + elements.confirmExportButton.textContent = `Export ${meta.label}`; + elements.confirmExportButton.disabled = recipe && !recipePortable; + } + renderExportPreview(); +} + +function openExportPreflight(format = "png") { + if (!EXPORT_FORMATS[format] || !elements.exportDialog) return; + exportReturnFocus = document.activeElement; + if (elements.exportFormatSelect) elements.exportFormatSelect.value = format; + updateExportPreflight({ resetFilename: true }); + if (typeof elements.exportDialog.showModal === "function") { + elements.exportDialog.showModal(); + } else { + elements.exportDialog.setAttribute("open", ""); + } + elements.exportFormatSelect?.focus(); +} + +function closeExportPreflight() { + if (!elements.exportDialog?.open) return; + if (typeof elements.exportDialog.close === "function") elements.exportDialog.close(); + else elements.exportDialog.removeAttribute("open"); + if (exportReturnFocus?.isConnected) exportReturnFocus.focus(); + exportReturnFocus = null; +} + +async function performPreflightExport() { + const format = elements.exportFormatSelect?.value || "png"; + const meta = EXPORT_FORMATS[format] || EXPORT_FORMATS.png; + const filename = sanitizeExportFilename( + elements.exportFilenameInput?.value, + format, + ); + const size = Number(elements.exportSizeSelect?.value) || 512; + const background = elements.exportBackgroundInput?.checked !== false; + const button = elements.confirmExportButton; + if (button) { + button.disabled = true; + button.textContent = format === "animated-svg" ? "Rendering frames…" : "Preparing…"; + } + try { + if (format === "png") { + await downloadStudioPNG(filename, { background }); + } else if (format === "svg") { + await engine.downloadSVG(filename, { size, background }); + } else if (format === "animated-svg") { + await engine.downloadAnimatedSVG(filename, { + size, + background, + duration: 2.4, + fps: 12, + }); + } else { + downloadJSON(filename); + } + closeExportPreflight(); + showWorkflowStatus(`Exported ${filename}.`, { tone: "success" }); + } catch (error) { + showWorkflowStatus(error?.message || `${meta.label} export failed.`, { + tone: "danger", + }); + } finally { + if (button) { + button.disabled = format === "config" && !builtInSpriteIds.has(engine.currentRecipe.id); + button.textContent = `Export ${meta.label}`; + } + } +} + +function simulateAudio() { + if (audioTimer !== null) { + window.clearInterval(audioTimer); + audioTimer = null; + } + selectSprite("ai.speaking"); + const started = performance.now(); + audioTimer = window.setInterval(() => { + const elapsed = (performance.now() - started) / 1000; + const level = + (0.2 + 0.8 * Math.abs(Math.sin(elapsed * 8.7) * Math.sin(elapsed * 3.1 + 0.4))) * + Math.max(0, 1 - elapsed / 2.6); + engine.setAudioLevel(level); + if (elapsed > 2.6) { + window.clearInterval(audioTimer); + audioTimer = null; + engine.setAudioLevel(0); + } + }, 45); +} + +elements.playButton?.addEventListener("click", () => { + engine.toggle(); + updatePlayButton(); +}); +elements.autopilotButton?.addEventListener("click", toggleAutopilot); +elements.themeButton?.addEventListener("click", () => setTheme(!isLight)); +elements.randomizeButton?.addEventListener("click", randomSeed); +for (const input of elements.paletteInputs) { + input.addEventListener("input", (event) => { + const key = event.currentTarget.dataset.paletteColor; + beginMutation(`palette-${key}`, `change ${key} color`); + customNonErrorPalette = { + ...customNonErrorPalette, + [key]: event.currentTarget.value, + }; + scheduleStudioPaletteUpdate(); + }); + input.addEventListener("change", () => { + endMutation(`palette-${input.dataset.paletteColor}`); + flushStudioPaletteUpdate(); + }); +} +elements.glowEnabledInput?.addEventListener("change", (event) => { + pushUndoSnapshot(event.currentTarget.checked ? "enable glow" : "disable glow"); + const enabled = event.currentTarget.checked; + const nextPalette = { ...customNonErrorPalette }; + + if (enabled) { + if (hadSavedGlowOverride) nextPalette.glow = savedGlowOverride; + else delete nextPalette.glow; + savedGlowOverride = null; + hadSavedGlowOverride = false; + } else { + const currentGlow = nextPalette.glow; + hadSavedGlowOverride = + typeof currentGlow === "string" && + currentGlow.trim().toLowerCase() !== "transparent"; + savedGlowOverride = hadSavedGlowOverride ? currentGlow : null; + nextPalette.glow = "transparent"; + } + + customNonErrorPalette = nextPalette; + applyStudioPalette(); +}); +elements.resetPaletteButton?.addEventListener("click", () => { + if (!Object.keys(customNonErrorPalette).length) return; + pushUndoSnapshot("reset custom colors"); + customNonErrorPalette = {}; + savedGlowOverride = null; + hadSavedGlowOverride = false; + applyStudioPalette(); +}); +elements.copyCodeButton?.addEventListener("click", copyCode); +elements.openExportButton?.addEventListener("click", () => openExportPreflight("png")); +elements.exportConfigButton?.addEventListener("click", () => + openExportPreflight("config"), +); +elements.exportPngButton?.addEventListener("click", () => openExportPreflight("png")); +elements.exportSvgButton?.addEventListener("click", () => openExportPreflight("svg")); +elements.exportAnimatedSvgButton?.addEventListener("click", () => + openExportPreflight("animated-svg"), +); +elements.undoStudioButton?.addEventListener("click", undoStudioChange); +elements.resetOverridesButton?.addEventListener("click", resetStudioOverrides); +elements.savePresetButton?.addEventListener("click", saveCurrentPreset); +elements.savePresetGalleryButton?.addEventListener("click", saveCurrentPreset); + +elements.fieldSelect?.addEventListener("change", (event) => { + pushUndoSnapshot("change the noise field"); + if (event.target.value === "__recipe__") engine.useRecipeFields(); + else engine.setField(event.target.value); + updateWorkflowState(); + updateCode(); +}); +elements.transitionSelect?.addEventListener("change", (event) => { + pushUndoSnapshot("change the transition"); + if (event.target.value === "__recipe__") { + transitionOverridden = false; + transitionOverrideValue = null; + engine.setOptions({ transition: null }); + } else { + transitionOverridden = true; + transitionOverrideValue = event.target.value; + engine.setOptions({ transition: transitionOverrideValue }); + engine.setSprite(engine.currentRecipe, { + transition: transitionOverrideValue, + duration: 0.55, + }); + } + updateWorkflowState(); + updateCode(); +}); +elements.pixelShapeSelect?.addEventListener("change", (event) => { + pushUndoSnapshot("change pixel geometry"); + pixelShapeOverridden = true; + engine.setPixelShape(event.target.value); + updateWorkflowState(); + updateCode(); +}); +elements.pixelSwitchSelect?.addEventListener("change", (event) => { + pushUndoSnapshot("change the switch system"); + pixelSwitchOverridden = true; + engine.setPixelSwitch(event.target.value); + updateWorkflowState(); + updateCode(); +}); +elements.orbBoundaryInput?.addEventListener("change", (event) => { + pushUndoSnapshot( + event.currentTarget.checked + ? "use a Gestalt orb boundary" + : "use a defined orb boundary", + ); + engine.setOptions({ + orbBoundary: event.currentTarget.checked ? "gestalt" : "defined", + }); + syncOrbAppearanceToPreviews(); + updateStateUI(engine.currentRecipe); +}); +elements.orbBackgroundEnabledInput?.addEventListener("change", (event) => { + pushUndoSnapshot( + event.currentTarget.checked + ? "enable the orb background" + : "remove the orb background", + ); + if (event.currentTarget.checked) { + const selectedColor = + normalizedOrbBackgroundColor(orbBackgroundColorChoice) || + elements.orbBackgroundColorInput?.value || + "#262626"; + orbBackgroundColorChoice = selectedColor; + engine.setOptions({ + orbBackgroundColor: selectedColor, + orbBackgroundMode: orbBackgroundModeChoice, + }); + } else { + const activeColor = normalizedOrbBackgroundColor( + engine.options.orbBackgroundColor, + ); + if (activeColor) orbBackgroundColorChoice = activeColor; + const activeMode = normalizedOrbBackgroundMode( + engine.options.orbBackgroundMode, + activeColor, + ); + if (activeMode !== "none") orbBackgroundModeChoice = activeMode; + engine.setOptions({ orbBackgroundColor: null, orbBackgroundMode: "none" }); + } + syncOrbAppearanceToPreviews(); + setOrbBackgroundControlState(); + updateWorkflowState(); + updateCode(); +}); +elements.orbBackgroundModeSelect?.addEventListener("change", (event) => { + const mode = normalizedOrbBackgroundMode(event.currentTarget.value); + if (mode === "none") return; + pushUndoSnapshot( + mode === "pixelated" + ? "use a pixelated orb background" + : "use a solid orb background", + ); + orbBackgroundModeChoice = mode; + engine.setOptions({ orbBackgroundMode: mode }); + syncOrbAppearanceToPreviews(); + setOrbBackgroundControlState(); + updateWorkflowState(); + updateCode(); +}); +elements.orbBackgroundColorInput?.addEventListener("input", (event) => { + beginMutation("orb-background-color", "change the orb background color"); + orbBackgroundColorChoice = event.currentTarget.value; + engine.options.orbBackgroundColor = orbBackgroundColorChoice; + engine.paint(); + syncOrbAppearanceToPreviews(); + setOrbBackgroundControlState( + orbBackgroundColorChoice, + true, + orbBackgroundModeChoice, + ); + scheduleStudioMetadataUpdate(); +}); +elements.orbBackgroundColorInput?.addEventListener("change", () => { + engine.setOptions({ + orbBackgroundColor: orbBackgroundColorChoice, + orbBackgroundMode: orbBackgroundModeChoice, + }); + syncOrbAppearanceToPreviews(); + endMutation("orb-background-color"); + updateWorkflowState(); + updateCode(); +}); +elements.resolutionRange?.addEventListener("input", (event) => { + beginMutation("resolution", "change resolution"); + const value = Number(event.target.value); + if (elements.resolutionValue) elements.resolutionValue.textContent = `${value}×${value}`; + scheduleResolutionCommit(value); +}); +elements.resolutionRange?.addEventListener("change", () => { + const value = commitPendingResolution(); + endMutation("resolution"); + schedulePreviewResolutionSync(value); +}); +elements.speedRange?.addEventListener("input", (event) => { + beginMutation("speed", "change velocity"); + const value = Number(event.target.value); + if (elements.speedValue) elements.speedValue.textContent = `${formatNumber(value)}×`; + engine.setOptions({ speed: value }); + scheduleStudioMetadataUpdate(); +}); +elements.speedRange?.addEventListener("change", () => endMutation("speed")); +elements.densityRange?.addEventListener("input", (event) => { + beginMutation("density", "change density"); + const value = Number(event.target.value); + if (elements.densityValue) elements.densityValue.textContent = formatNumber(value); + engine.setOptions({ density: value }); + scheduleStudioMetadataUpdate(); +}); +elements.densityRange?.addEventListener("change", () => endMutation("density")); +elements.seedInput?.addEventListener("change", (event) => { + pushUndoSnapshot("change the seed"); + engine.setSeed(event.target.value.trim() || "joan-v5"); + elements.seedInput.value = engine.seed; + updateWorkflowState(); + updateCode(); +}); +elements.progressRange?.addEventListener("input", (event) => { + beginMutation("progress", "change semantic progress"); + const value = Number(event.target.value) / 100; + engine.setProgress(value); + for (const id of ["ai.progress", "transfer.active"]) { + const preview = previewById.get(id); + if (!preview) continue; + preview.setProgress(value); + if (!previewShouldAnimate(id)) freezePreview(preview); + } + if (elements.progressValue) elements.progressValue.textContent = `${Math.round(value * 100)}%`; +}); +elements.progressRange?.addEventListener("change", () => endMutation("progress")); +elements.signalTokenButton?.addEventListener("click", () => { + selectSprite("ai.generating"); + engine.signal("token", { energy: 0.9 }); +}); +elements.signalSearchButton?.addEventListener("click", () => { + selectSprite("ai.searching"); + engine.signal("search.hit", { + energy: 0.9, + x: -0.5 + Math.random(), + y: -0.5 + Math.random(), + }); +}); +elements.signalAudioButton?.addEventListener("click", simulateAudio); +elements.previewFitButton?.addEventListener("click", () => applyPreviewMode("fit")); +elements.previewActualButton?.addEventListener("click", () => + applyPreviewMode("actual"), +); + +elements.stateSearchInput?.addEventListener("input", (event) => { + buildStatePicker(event.currentTarget.value); +}); +elements.stateSearchInput?.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !event.currentTarget.value) return; + event.preventDefault(); + event.currentTarget.value = ""; + buildStatePicker(); +}); +elements.statePickerSelect?.addEventListener("change", (event) => { + if (spriteById.has(event.target.value)) selectSprite(event.target.value); +}); +elements.recentPresetSelect?.addEventListener("change", (event) => { + openRecentPreset(event.target.value); + event.target.value = ""; +}); +elements.savedPresetGallery?.addEventListener("click", (event) => { + const deleteButton = event.target.closest("[data-delete-preset]"); + if (deleteButton) { + deleteRecentPreset(deleteButton.dataset.deletePreset); + return; + } + const openButton = event.target.closest("[data-open-preset]"); + if (openButton) openRecentPreset(openButton.dataset.openPreset); +}); +elements.undoDeletePresetButton?.addEventListener("click", undoDeletedPreset); + +elements.exportFormatSelect?.addEventListener("change", () => + updateExportPreflight({ resetFilename: true }), +); +elements.exportSizeSelect?.addEventListener("change", () => updateExportPreflight()); +elements.exportBackgroundInput?.addEventListener("change", () => + updateExportPreflight(), +); +elements.exportForm?.addEventListener("submit", (event) => { + event.preventDefault(); + void performPreflightExport(); +}); +elements.closeExportButton?.addEventListener("click", closeExportPreflight); +elements.cancelExportButton?.addEventListener("click", closeExportPreflight); +elements.exportDialog?.addEventListener("click", (event) => { + if (event.target === elements.exportDialog) closeExportPreflight(); +}); +elements.exportDialog?.addEventListener("close", () => { + if (exportReturnFocus?.isConnected) exportReturnFocus.focus(); + exportReturnFocus = null; +}); + +elements.openRecipeButton?.addEventListener("click", () => elements.recipeFile?.click()); +elements.recipeFile?.addEventListener("change", async (event) => { + const [file] = event.target.files || []; + if (!file) return; + elements.openRecipeButton.disabled = true; + elements.openRecipeButton.textContent = "Opening…"; + try { + await openRecipeFile(file); + } catch (error) { + showWorkflowStatus(error?.message || "The recipe could not be opened.", { + tone: "danger", + }); + } finally { + elements.openRecipeButton.disabled = false; + elements.openRecipeButton.textContent = "Open recipe"; + event.target.value = ""; + } +}); + +elements.importButton?.addEventListener("click", () => elements.glyphFile?.click()); +elements.glyphFile?.addEventListener("change", async (event) => { + const [file] = event.target.files || []; + if (!file) return; + elements.importButton.disabled = true; + elements.importButton.textContent = "Rasterizing…"; + try { + pushUndoSnapshot("import artwork"); + await engine.loadGlyphFile(file, { + id: "custom.product-glyph", + label: file.name.replace(/\.[^.]+$/, ""), + }); + spriteById.set(engine.currentRecipe.id, engine.currentRecipe); + updateStateUI(engine.currentRecipe); + showWorkflowStatus(`Imported ${file.name}.`, { tone: "success" }); + } catch (error) { + showWorkflowStatus(error.message, { tone: "danger" }); + } finally { + elements.importButton.disabled = false; + elements.importButton.textContent = "Import artwork"; + event.target.value = ""; + } +}); + +for (const button of document.querySelectorAll("[data-quick-sprite]")) { + button.addEventListener("click", () => selectSprite(button.dataset.quickSprite)); +} + +elements.canvas?.addEventListener("dragover", (event) => { + if (!event.dataTransfer?.types.includes("Files")) return; + event.preventDefault(); + elements.canvas.closest(".canvas-frame")?.classList.add("is-dragging"); +}); +elements.canvas?.addEventListener("dragleave", () => { + elements.canvas.closest(".canvas-frame")?.classList.remove("is-dragging"); +}); +elements.canvas?.addEventListener("drop", async (event) => { + event.preventDefault(); + elements.canvas.closest(".canvas-frame")?.classList.remove("is-dragging"); + const [file] = event.dataTransfer?.files || []; + if (file) { + pushUndoSnapshot("import artwork"); + await engine.loadGlyphFile(file, { + id: "custom.product-glyph", + label: file.name.replace(/\.[^.]+$/, ""), + }); + spriteById.set(engine.currentRecipe.id, engine.currentRecipe); + updateStateUI(engine.currentRecipe); + showWorkflowStatus(`Imported ${file.name}.`, { tone: "success" }); + } +}); + +engine.addEventListener("stats", (event) => { + if (elements.fpsValue) elements.fpsValue.textContent = String(event.detail.fps || "—"); + if (elements.activeValue) { + elements.activeValue.textContent = event.detail.activePixels.toLocaleString(); + } +}); +engine.addEventListener("play", updatePlayButton); +engine.addEventListener("pause", () => { + updatePlayButton(); + freezePreviewIfInactive(selectedSpriteId); +}); +engine.addEventListener("timelinecomplete", (event) => { + const recipe = spriteById.get(event.detail?.to); + if (recipe) updateStateUI(recipe); +}); + +document.addEventListener("keydown", (event) => { + if (event.defaultPrevented || elements.exportDialog?.open) return; + const tag = event.target?.tagName; + if (["INPUT", "SELECT", "TEXTAREA", "BUTTON", "CANVAS"].includes(tag)) return; + if (event.code === "Space") { + event.preventDefault(); + engine.toggle(); + updatePlayButton(); + } else if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + const index = Math.max(0, sprites.findIndex((sprite) => sprite.id === selectedSpriteId)); + const direction = event.key === "ArrowRight" ? 1 : -1; + selectSprite(sprites[(index + direction + sprites.length) % sprites.length].id); + } else if (event.key.toLowerCase() === "r") { + randomSeed(); + } else if (event.key.toLowerCase() === "a") { + toggleAutopilot(); + } +}); + +reduceMotion.addEventListener("change", () => { + previewLast = 0; + for (const preview of previewEngines) { + preview.reducedMotion = reduceMotion.matches; + } + schedulePreviewRefresh(); +}); + +window.addEventListener("beforeunload", () => { + stopAutopilot(); + cancelPendingResolutionCommit(); + if (audioTimer !== null) window.clearInterval(audioTimer); + previewsDisposed = true; + previewWorkQueue.length = 0; + previewCardObserver?.disconnect(); + savedPresetPreviewObserver?.disconnect(); + heroEngine?.destroy(); + engine.destroy(); + for (const preview of previewEngines) preview.destroy(); +}); diff --git a/web/vendor/src/styles.css b/web/vendor/src/styles.css new file mode 100644 index 0000000..82a5252 --- /dev/null +++ b/web/vendor/src/styles.css @@ -0,0 +1,4273 @@ +/* Orby — A procedural glyph engine by Joan Sterjo. */ + +:root { + color-scheme: dark; + --bg: #050505; + --bg-raised: #080808; + --surface: #0b0b0c; + --surface-raised: #0f1011; + --surface-hover: #141516; + --canvas: #070708; + --ink: #f2f1ee; + --ink-soft: #c9c8c4; + --muted: #8b8d90; + --faint: #787b7f; + --line: #232426; + --line-strong: #35373a; + --accent: #c9c8c4; + --accent-strong: #f2f1ee; + --accent-wash: rgba(232, 232, 228, 0.1); + --cool: #aeb4bb; + --success: #97d9b0; + --danger: #ff9f99; + --on-accent: #090a0b; + --control-line: #232426; + --body-accent-glow: rgba(238, 238, 232, 0.045); + --body-cool-glow: rgba(174, 180, 187, 0.025); + --ambient-grid: rgba(255, 255, 255, 0.018); + --surface-sheen: rgba(255, 255, 255, 0.018); + --canvas-glow: rgba(226, 228, 230, 0.05); + --canvas-grid: rgba(255, 255, 255, 0.025); + --canvas-scan: rgba(210, 214, 218, 0.035); + --shadow: 0 28px 80px rgba(0, 0, 0, 0.38); + --shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.18); + --shadow-card: 0 12px 28px rgba(0, 0, 0, 0.18); + --shadow-card-selected: 0 10px 30px rgba(0, 0, 0, 0.2); + --shadow-float: + 0 16px 42px rgba(0, 0, 0, 0.34), + 0 0 0 1px rgba(255, 255, 255, 0.025) inset; + --shadow-dialog: 0 38px 110px rgba(0, 0, 0, 0.58); + --inset-edge: rgba(0, 0, 0, 0.25); + --backdrop: rgba(0, 0, 0, 0.74); + --checker-base: #0c0c0d; + --checker-tile: #191a1c; + --topbar-bg: color-mix(in srgb, var(--bg), transparent 10%); + --topbar-shadow: none; + --code-bg: #080808; + --code-hover: #121314; + --code-line: #242528; + --code-line-strong: #47494e; + --code-muted: #777a7f; + --code-control: #2c2e31; + --code-control-ink: #a4a6a9; + --code-ink: #c0c1c2; + --code-ink-strong: #f2f1ee; + --code-dot-1: #414244; + --code-dot-2: #66686c; + --code-dot-3: #aeb0b4; + --code-grid: rgba(255, 255, 255, 0.025); + --code-scrollbar: #34363a; + --scrollbar-thumb: #35373a; + --scrollbar-track: #080808; + --font-sans: + "SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + --font-mono: + "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, monospace; + --content: min(1480px, calc(100vw - 56px)); + --radius-lg: 18px; + --radius-md: 11px; + --ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +html[data-theme="light"] { + color-scheme: light; + --bg: #eeece6; + --bg-raised: #f4f2ec; + --surface: #faf9f5; + --surface-raised: #ffffff; + --surface-hover: #f1efe8; + --canvas: #e5e6e2; + --ink: #17191e; + --ink-soft: #30343b; + --muted: #565c65; + --faint: #626973; + --line: #cbc9c1; + --line-strong: #898b87; + --accent: #4d5054; + --accent-strong: #25272a; + --accent-wash: rgba(37, 39, 42, 0.09); + --cool: #505a64; + --success: #256b45; + --danger: #a43836; + --on-accent: #ffffff; + --control-line: #898b87; + --body-accent-glow: rgba(45, 47, 50, 0.045); + --body-cool-glow: rgba(80, 90, 100, 0.025); + --ambient-grid: rgba(30, 34, 45, 0.04); + --surface-sheen: rgba(20, 24, 32, 0.022); + --canvas-glow: rgba(45, 47, 50, 0.055); + --canvas-grid: rgba(25, 28, 35, 0.05); + --canvas-scan: rgba(63, 66, 70, 0.04); + --shadow: + 0 1px 2px rgba(30, 34, 44, 0.08), + 0 18px 48px rgba(30, 34, 44, 0.07); + --shadow-soft: 0 8px 24px rgba(34, 36, 42, 0.08); + --shadow-card: 0 12px 28px rgba(34, 36, 42, 0.1); + --shadow-card-selected: 0 12px 30px rgba(38, 40, 45, 0.12); + --shadow-float: + 0 16px 42px rgba(34, 36, 42, 0.14), + 0 0 0 1px rgba(20, 24, 32, 0.025) inset; + --shadow-dialog: 0 32px 90px rgba(24, 26, 32, 0.22); + --inset-edge: rgba(30, 34, 44, 0.14); + --backdrop: rgba(28, 31, 38, 0.34); + --checker-base: #f7f7f3; + --checker-tile: #deded8; + --topbar-bg: rgba(250, 249, 245, 0.9); + --topbar-shadow: 0 1px 0 rgba(30, 34, 44, 0.05), 0 8px 24px rgba(30, 34, 44, 0.045); + --code-bg: #f4f3ef; + --code-hover: #e9e9e6; + --code-line: #cbc9c1; + --code-line-strong: #898b87; + --code-muted: #5a616c; + --code-control: #b7b8b5; + --code-control-ink: #565d69; + --code-ink: #2a3039; + --code-ink-strong: #171b22; + --code-dot-1: #b7bac1; + --code-dot-2: #85888d; + --code-dot-3: #54575c; + --code-grid: rgba(65, 68, 72, 0.04); + --code-scrollbar: #b9bbb7; + --scrollbar-thumb: #a6a8a5; + --scrollbar-track: #e6e4de; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + background: var(--bg); +} + +body { + min-height: 100vh; + margin: 0; + overflow-x: hidden; + background: + radial-gradient(circle at 76% 2%, var(--body-accent-glow), transparent 26rem), + radial-gradient(circle at 9% 32%, var(--body-cool-glow), transparent 30rem), + var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select { + color: inherit; + font: inherit; +} + +button, +select, +input[type="range"] { + cursor: pointer; +} + +button { + border: 0; +} + +a { + color: inherit; +} + +::selection { + background: color-mix(in srgb, var(--accent), transparent 72%); + color: var(--ink); +} + +html { + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: var(--scrollbar-track); +} + +*::-webkit-scrollbar-thumb { + border: 2px solid var(--scrollbar-track); + border-radius: 999px; + background: var(--scrollbar-thumb); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.skip-link { + position: fixed; + z-index: 100; + top: 12px; + left: 12px; + padding: 10px 14px; + border: 1px solid var(--accent); + border-radius: 7px; + background: var(--surface-raised); + color: var(--ink); + font-family: var(--font-mono); + font-size: 12px; + text-decoration: none; + transform: translateY(-180%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.ambient-field { + position: fixed; + z-index: -1; + inset: 0; + pointer-events: none; + opacity: 0.5; + background-image: radial-gradient( + circle, + var(--ambient-grid) 0 1px, + transparent 1.25px + ); + background-size: 12px 12px; + -webkit-mask-image: linear-gradient(to bottom, black, transparent 72%); + mask-image: linear-gradient(to bottom, black, transparent 72%); +} + +html[data-theme="light"] .ambient-field { + opacity: 0.55; +} + +.topbar { + position: sticky; + z-index: 40; + top: 0; + border-bottom: 1px solid color-mix(in srgb, var(--line), transparent 16%); + background: var(--topbar-bg); + box-shadow: var(--topbar-shadow); + backdrop-filter: blur(20px) saturate(130%); +} + +.topbar__inner { + width: var(--content); + min-height: 68px; + margin-inline: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.site-nav { + margin-left: auto; + display: flex; + align-items: center; + gap: 3px; +} + +.site-nav a { + min-height: 34px; + padding: 0 10px; + display: inline-flex; + align-items: center; + border: 1px solid transparent; + border-radius: 5px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.075em; + text-decoration: none; + text-transform: uppercase; + transition: + border-color 160ms ease, + background-color 160ms ease, + color 160ms ease; +} + +.site-nav a::before { + margin-right: 5px; + color: var(--faint); + content: "/"; +} + +.site-nav a:hover, +.site-nav a:focus-visible { + border-color: var(--line); + background: var(--surface-hover); + color: var(--ink); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 11px; + min-width: 0; + color: var(--ink); + text-decoration: none; +} + +.brand__mark { + width: 40px; + height: 40px; + display: block; + flex: 0 0 auto; +} + +.brand__mark img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +html[data-theme="light"] .brand__mark img { + filter: invert(1); +} + +.brand__copy { + display: grid; + line-height: 1.1; +} + +.brand__copy strong { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.brand__copy small { + margin-top: 4px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.version-pill, +.deterministic-badge { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; +} + +.version-pill { + padding: 5px 7px; +} + +.topbar__actions { + margin-left: 10px; + display: flex; + align-items: center; + gap: 8px; +} + +.runtime-status { + display: inline-flex; + align-items: center; + gap: 7px; + margin-right: 7px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.runtime-status__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--success), transparent 65%); + animation: status-pulse 2.8s ease-out infinite; +} + +.button { + min-height: 38px; + padding: 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--control-line); + border-radius: 7px; + background: var(--surface); + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 550; + letter-spacing: 0.055em; + line-height: 1; + text-transform: uppercase; + text-decoration: none; + transition: + border-color 180ms ease, + background-color 180ms ease, + color 180ms ease, + transform 180ms var(--ease); +} + +.button:hover:not(:disabled) { + border-color: var(--line-strong); + background: var(--surface-hover); + color: var(--ink); + transform: translateY(-1px); +} + +.button:active:not(:disabled) { + transform: translateY(0); +} + +.button:disabled { + cursor: wait; + opacity: 0.55; +} + +.button--quiet { + background: transparent; +} + +.button--primary { + border-color: var(--ink); + background: var(--ink); + color: var(--bg); +} + +.button--primary:hover:not(:disabled), +.button--primary.is-active { + border-color: var(--accent-strong); + background: var(--accent-strong); + color: var(--on-accent); +} + +.button--square { + width: 42px; + min-width: 42px; + height: 42px; + padding: 0; + font-size: 17px; +} + +.theme-button::before { + width: 10px; + height: 10px; + border: 1px solid currentColor; + border-radius: 50%; + background: linear-gradient(90deg, currentColor 50%, transparent 50%); + content: ""; +} + +main { + width: var(--content); + margin-inline: auto; +} + +#overview, +#playground, +#states, +#api, +#download { + scroll-margin-top: 92px; +} + +.hero { + min-height: 700px; + padding: clamp(70px, 8vw, 118px) 0 clamp(76px, 8vw, 112px); + display: grid; + grid-template-columns: minmax(0, 1.12fr) minmax(390px, 0.88fr); + align-items: center; + gap: clamp(52px, 7vw, 112px); + border-bottom: 1px solid var(--line); +} + +.hero__copy { + max-width: 900px; +} + +.eyebrow, +.section-index { + margin: 0; + color: var(--muted); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.16em; + line-height: 1.4; + text-transform: uppercase; +} + +.eyebrow { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.eyebrow span { + color: var(--accent-strong); +} + +.eyebrow::after { + width: 42px; + height: 1px; + content: ""; + background: var(--line-strong); +} + +.hero h1 { + max-width: 940px; + margin: 0; + color: var(--ink); + font-size: clamp(3.8rem, 6.2vw, 7rem); + font-weight: 260; + letter-spacing: -0.065em; + line-height: 0.91; +} + +.hero__lede { + max-width: 720px; + margin: 28px 0 0; + color: var(--muted); + font-size: clamp(15px, 1.3vw, 18px); + font-weight: 350; + line-height: 1.68; +} + +.landing-hero__actions { + margin-top: 34px; + display: flex; + flex-wrap: wrap; + gap: 9px; +} + +.landing-hero__actions .button { + min-height: 48px; + padding-inline: 18px; +} + +.landing-hero__visual { + --hero-proof-height: 90px; + --hero-proof-offset: 45px; + position: relative; + min-height: clamp(500px, 40vw, 590px); + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: + linear-gradient(90deg, var(--ambient-grid) 1px, transparent 1px) 0 0 / 24px 24px, + linear-gradient(var(--ambient-grid) 1px, transparent 1px) 0 0 / 24px 24px, + radial-gradient(circle at 50% 43%, var(--body-accent-glow), transparent 48%), + color-mix(in srgb, var(--surface), transparent 24%); + box-shadow: var(--shadow); + isolation: isolate; +} + +.landing-hero__visual::before, +.landing-hero__visual::after { + position: absolute; + z-index: 4; + pointer-events: none; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.landing-hero__visual::before { + top: 17px; + left: 18px; + content: "FIELD / SEMANTIC PRESENCE"; +} + +.landing-hero__visual::after { + top: 17px; + right: 18px; + content: "LIVE 68×68"; +} + +.hero-engine-output { + position: absolute; + z-index: 2; + top: calc(50% - var(--hero-proof-offset)); + left: 50%; + width: min(78%, 450px); + aspect-ratio: 1; + pointer-events: none; + transform: translate(-50%, -50%); + contain: layout paint; +} + +.hero-engine-output canvas { + width: 100%; + height: 100%; + display: block; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.hero-proof { + position: absolute; + z-index: 3; + right: 0; + bottom: 0; + left: 0; + min-height: 90px; + margin: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border-top: 1px solid var(--line); + background: color-mix(in srgb, var(--bg-raised), transparent 5%); + backdrop-filter: blur(14px); +} + +.hero-proof div { + min-width: 0; + padding: 18px 20px; + display: grid; + align-content: center; + gap: 4px; + border-right: 1px solid var(--line); +} + +.hero-proof div:last-child { + border-right: 0; +} + +.hero-proof dt { + color: var(--ink); + font-family: var(--font-mono); + font-size: 18px; + line-height: 1; +} + +.hero-proof dd { + margin: 0; + overflow: hidden; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + line-height: 1.35; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.principles-section { + padding: clamp(88px, 9vw, 132px) 0; + border-bottom: 1px solid var(--line); +} + +.principles-section__heading { + display: grid; + grid-template-columns: minmax(150px, 0.36fr) minmax(0, 1fr); + align-items: start; + gap: clamp(34px, 8vw, 120px); +} + +.principles-section__heading h2, +.studio-intro h2, +.usage-section__heading h2 { + margin: 0; + color: var(--ink); + font-size: clamp(2.5rem, 4.8vw, 5.25rem); + font-weight: 270; + letter-spacing: -0.055em; + line-height: 0.98; +} + +.principles-grid { + margin-top: clamp(46px, 6vw, 82px); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border: 1px solid var(--line); + background: var(--line); + gap: 1px; +} + +.principle-card { + position: relative; + min-height: 260px; + padding: 28px; + overflow: hidden; + background: + radial-gradient(circle, var(--ambient-grid) 0 1px, transparent 1.25px) 0 0 / 12px 12px, + var(--bg-raised); +} + +.principle-card::after { + position: absolute; + right: -38px; + bottom: -38px; + width: 116px; + height: 116px; + border: 1px dashed var(--line-strong); + border-radius: 50%; + content: ""; +} + +.principle-card__index { + color: var(--faint); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.12em; +} + +.principle-card h3 { + max-width: 270px; + margin: 72px 0 0; + color: var(--ink-soft); + font-size: 21px; + font-weight: 430; + letter-spacing: -0.025em; +} + +.principle-card p { + max-width: 36ch; + margin: 12px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.68; +} + +.studio-intro { + padding: clamp(80px, 8vw, 116px) 0 36px; + display: grid; + grid-template-columns: minmax(0, 0.88fr) minmax(300px, 0.72fr); + align-items: end; + justify-content: space-between; + gap: clamp(40px, 8vw, 120px); +} + +.studio-intro h2 { + max-width: 780px; + margin-top: 14px; +} + +.studio-intro > p { + max-width: 520px; + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.72; +} + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--line); +} + +.hero__specs dt { + color: var(--ink); + font-family: var(--font-mono); + font-size: 17px; +} + +.hero__specs dd { + margin: 0; + color: var(--muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workbench { + padding: 28px 0 110px; + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + align-items: start; + gap: 18px; +} + +.panel { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: + linear-gradient(145deg, var(--surface-sheen), transparent 32%), + var(--surface); + box-shadow: var(--shadow); +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 28px; +} + +.stage-panel { + min-width: 0; + padding: 26px; +} + +.stage-heading { + min-height: 116px; + padding: 2px 2px 22px; +} + +.state-heading { + margin-top: 11px; + display: flex; + align-items: center; + gap: 12px; +} + +.state-heading h2, +.control-heading h2 { + margin: 0; + color: var(--ink); + font-size: 24px; + font-weight: 440; + letter-spacing: -0.025em; +} + +.state-category { + padding: 5px 7px; + border: 1px solid var(--line-strong); + border-radius: 4px; + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.09em; + line-height: 1; + text-transform: uppercase; +} + +.state-description { + max-width: 64ch; + min-height: 36px; + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} + +.signal-cluster { + flex: 0 0 auto; + text-align: right; +} + +.signal-cluster__label { + display: block; + margin: 0 1px 8px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.signal-cluster__buttons { + display: flex; + gap: 5px; +} + +.signal-button { + min-width: 64px; + min-height: 35px; + padding: 0 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid var(--control-line); + border-radius: 6px; + background: transparent; + color: var(--muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.04em; + transition: + color 160ms ease, + border-color 160ms ease, + background-color 160ms ease; +} + +.signal-button::before { + width: 6px; + height: 6px; + content: ""; + background: currentColor; + box-shadow: 4px 0 0 color-mix(in srgb, currentColor, transparent 56%); +} + +.signal-button--search::before { + border: 1px solid currentColor; + border-radius: 50%; + background: transparent; + box-shadow: 4px 4px 0 -2px currentColor; +} + +.signal-button--audio::before { + width: 2px; + height: 8px; + box-shadow: + 4px -2px 0 currentColor, + 8px 1px 0 currentColor; +} + +.signal-button:hover { + border-color: color-mix(in srgb, var(--accent), var(--line) 40%); + background: var(--accent-wash); + color: var(--accent-strong); +} + +.workflow-bar { + margin: 0 0 14px; + padding: 11px; + display: grid; + grid-template-columns: minmax(300px, 0.9fr) minmax(360px, 1.1fr); + align-items: end; + gap: 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: color-mix(in srgb, var(--bg-raised), transparent 14%); +} + +.state-picker { + min-width: 0; + display: grid; + grid-template-columns: minmax(128px, 0.72fr) minmax(160px, 1fr); + gap: 7px; +} + +.state-picker label, +.export-field { + min-width: 0; + display: grid; + gap: 6px; +} + +.state-picker label > span:first-child, +.export-field > span:first-child { + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.state-picker input, +.state-picker select, +.recent-preset select, +.export-field input, +.export-field select { + width: 100%; + height: 36px; + border: 1px solid var(--control-line); + border-radius: 6px; + outline: 0; + background: var(--surface); + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 9px; +} + +.state-picker input, +.export-field input { + padding: 0 10px; +} + +.state-picker input::placeholder { + color: var(--faint); +} + +.state-picker input:hover, +.state-picker select:hover, +.recent-preset select:hover, +.export-field input:hover, +.export-field select:hover { + border-color: var(--line-strong); +} + +.state-picker input:focus-visible, +.state-picker select:focus-visible, +.recent-preset select:focus-visible, +.export-field input:focus-visible, +.export-field select:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +.recipe-workflow { + min-width: 0; + display: grid; + justify-items: end; + gap: 7px; +} + +.recipe-workflow__status { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; +} + +#workflowStatus { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#workflowStatus[data-tone="success"] { + color: var(--success); +} + +#workflowStatus[data-tone="warning"] { + color: var(--accent-strong); +} + +#workflowStatus[data-tone="danger"] { + color: var(--danger); +} + +.recipe-workflow__actions { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; +} + +.recent-preset { + min-width: 120px; + max-width: 150px; +} + +.recent-preset select { + height: 34px; + padding: 0 25px 0 8px; +} + +.workflow-button { + min-height: 34px; + padding: 0 8px; + border: 1px solid var(--control-line); + border-radius: 5px; + background: transparent; + color: var(--muted); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.04em; +} + +.workflow-button:hover:not(:disabled) { + border-color: var(--line-strong); + background: var(--surface-hover); + color: var(--ink-soft); +} + +.workflow-button:disabled, +.recent-preset select:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.workflow-export { + min-height: 34px; + padding-inline: 11px; + font-size: 8px; +} + +.scope-badge { + width: max-content; + max-width: 100%; + padding: 3px 5px; + display: inline-flex; + align-items: center; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 7px; + font-weight: 500; + letter-spacing: 0.06em; + line-height: 1; + text-transform: uppercase; + white-space: nowrap; +} + +.scope-badge[data-scope="custom"] { + border-color: color-mix(in srgb, var(--accent), transparent 64%); + background: var(--accent-wash); + color: var(--accent-strong); +} + +.scope-badge[data-scope="carry"] { + border-color: color-mix(in srgb, var(--success), transparent 72%); + color: var(--success); +} + +.canvas-frame { + position: relative; + width: min(100%, 820px); + aspect-ratio: 1; + margin-inline: auto; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: + radial-gradient(circle at 50% 48%, var(--canvas-glow), transparent 35%), + linear-gradient(var(--canvas-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--canvas-grid) 1px, transparent 1px), + var(--canvas); + background-size: + auto, + 24px 24px, + 24px 24px, + auto; + isolation: isolate; + transition: + border-color 180ms ease, + box-shadow 180ms ease; +} + +.canvas-frame::before, +.canvas-frame::after { + position: absolute; + z-index: 4; + pointer-events: none; + content: ""; +} + +.canvas-frame::before { + inset: 9px; + border: + solid color-mix(in srgb, var(--line-strong), transparent 18%); + border-width: 1px 0; + opacity: 0.55; + clip-path: polygon( + 0 0, + 38px 0, + 38px 1px, + calc(100% - 38px) 1px, + calc(100% - 38px) 0, + 100% 0, + 100% 100%, + calc(100% - 38px) 100%, + calc(100% - 38px) calc(100% - 1px), + 38px calc(100% - 1px), + 38px 100%, + 0 100% + ); +} + +.canvas-frame::after { + top: -22%; + right: 0; + left: 0; + height: 18%; + background: linear-gradient(to bottom, transparent, var(--canvas-scan), transparent); + animation: field-scan 9s linear infinite; +} + +.canvas-frame.is-actual-preview::before { + inset: 50% auto auto 50%; + width: calc(var(--preview-grid-size, 68px) + 10px); + height: calc(var(--preview-grid-size, 68px) + 10px); + border: 1px solid color-mix(in srgb, var(--accent), transparent 58%); + clip-path: none; + opacity: 1; + transform: translate(-50%, -50%); + box-shadow: 0 0 28px color-mix(in srgb, var(--accent), transparent 88%); +} + +.canvas-frame.is-actual-preview::after { + display: none; +} + +.canvas-frame.is-dragging { + border-color: var(--accent); + box-shadow: + 0 0 0 4px var(--accent-wash), + inset 0 0 80px var(--accent-wash); +} + +.canvas-frame.is-dragging::before { + inset: 22px; + display: grid; + place-items: center; + border: 1px dashed var(--accent); + clip-path: none; + content: "DROP GLYPH TO COMPILE"; + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.16em; +} + +#engineCanvas { + position: absolute; + z-index: 2; + inset: 0; + width: 100%; + height: 100%; + display: block; + touch-action: none; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +#engineCanvas:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -5px; + box-shadow: inset 0 0 0 6px color-mix(in srgb, var(--accent), transparent 78%); +} + +.frame-badge { + position: absolute; + z-index: 5; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.12em; + line-height: 1; + text-transform: uppercase; + pointer-events: none; +} + +.frame-badge--top { + top: 18px; + left: 19px; +} + +.frame-badge--top::before { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--success); + content: ""; + box-shadow: 0 0 10px color-mix(in srgb, var(--success), transparent 30%); +} + +.frame-badge--bottom { + right: 19px; + bottom: 18px; +} + +.preview-scale-control { + position: absolute; + z-index: 6; + top: 12px; + right: 12px; + padding: 3px; + display: inline-grid; + grid-auto-flow: column; + gap: 2px; + border: 1px solid var(--line-strong); + border-radius: 7px; + background: color-mix(in srgb, var(--surface), transparent 8%); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(10px); +} + +.preview-scale-control button { + min-width: 42px; + min-height: 32px; + padding: 0 9px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.preview-scale-control button:hover { + background: var(--surface-hover); + color: var(--ink); +} + +.preview-scale-control button[aria-pressed="true"] { + background: var(--accent-wash); + color: var(--accent-strong); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent), transparent 52%); +} + +.progress-control { + margin-top: 14px; + padding: 13px 15px; + display: none; + grid-template-columns: minmax(150px, 0.35fr) minmax(180px, 1fr); + align-items: center; + gap: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg-raised); +} + +.progress-control.is-visible, +#progressControl.is-visible { + display: grid; +} + +.progress-control label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.progress-control output { + color: var(--accent-strong); +} + +.stage-lower { + padding: 18px 2px 12px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 22px; +} + +.quick-states > span { + display: block; + margin-bottom: 7px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.quick-states > div { + display: flex; + gap: 4px; +} + +.quick-states button { + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--control-line); + border-radius: 5px; + background: transparent; + color: var(--muted); + font-family: var(--font-mono); + font-size: 9px; +} + +.quick-states button:hover { + border-color: var(--line-strong); + background: var(--surface-hover); + color: var(--ink); +} + +.quick-states button.is-selected, +.quick-states button[aria-pressed="true"] { + border-color: color-mix(in srgb, var(--accent), var(--line) 35%); + background: var(--accent-wash); + color: var(--accent-strong); +} + +.stage-metrics { + margin: 0; + display: flex; + justify-content: flex-end; + gap: 24px; + text-align: right; +} + +.stage-metrics div { + min-width: 52px; +} + +.stage-metrics dt { + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.stage-metrics dd { + margin: 4px 0 0; + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.stage-metrics__state { + min-width: 112px !important; +} + +.stage-actions { + padding: 17px 2px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-top: 1px solid var(--line); +} + +.stage-actions p { + margin: 0; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.03em; +} + +kbd { + min-width: 19px; + height: 19px; + margin: 0 3px; + padding: 0 5px; + display: inline-grid; + place-items: center; + border: 1px solid var(--line-strong); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--bg-raised); + color: var(--muted); + font-family: var(--font-mono); + font-size: 8px; + box-shadow: inset 0 -1px var(--inset-edge); +} + +.stage-actions > div { + display: flex; + gap: 7px; +} + +.play-button::before { + width: 8px; + height: 8px; + content: ""; + background: + linear-gradient(90deg, currentColor 0 2px, transparent 2px 5px, currentColor 5px); +} + +.control-panel { + position: sticky; + top: 86px; + overflow: hidden; +} + +.control-heading { + padding: 25px 24px 21px; + border-bottom: 1px solid var(--line); +} + +.control-heading h2 { + margin-top: 10px; + font-size: 20px; +} + +.control-heading__hint { + margin: 7px 0 0; + color: var(--faint); + font-size: 9px; + line-height: 1.45; +} + +.deterministic-badge { + padding: 6px 8px; + color: var(--success); +} + +.control-form { + margin: 0; +} + +.control-section { + margin: 0; + padding: 22px 24px 23px; + display: grid; + gap: 18px; + border: 0; + border-bottom: 1px solid var(--line); +} + +.control-section legend { + width: 100%; + padding: 18px 24px 0; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.control-section legend .scope-badge { + margin-left: 8px; + vertical-align: 1px; +} + +.control-label-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.control-with-tooltip { + position: relative; + cursor: help; +} + +button.control-with-tooltip { + cursor: pointer; +} + +.control-tooltip { + position: fixed; + z-index: 90; + width: max-content; + max-width: min(300px, calc(100vw - 24px)); + padding: 10px 11px; + border: 1px solid var(--control-line); + border-radius: 7px; + visibility: hidden; + background: color-mix(in srgb, var(--surface-raised), var(--bg) 18%); + box-shadow: var(--shadow-float); + color: var(--ink-soft); + font-family: var(--font-sans); + font-size: 10px; + font-weight: 400; + letter-spacing: 0; + line-height: 1.5; + opacity: 0; + pointer-events: none; + text-align: left; + text-transform: none; + transform: translateY(3px); + transition: + opacity 140ms ease, + transform 140ms ease, + visibility 140ms ease; +} + +.control-tooltip::before { + position: absolute; + left: 50%; + width: 7px; + height: 7px; + border: solid var(--control-line); + background: color-mix(in srgb, var(--surface-raised), var(--bg) 18%); + content: ""; + transform: translateX(-50%) rotate(45deg); +} + +.control-tooltip[data-placement="bottom"]::before { + top: -4px; + border-width: 1px 0 0 1px; +} + +.control-tooltip[data-placement="top"]::before { + bottom: -4px; + border-width: 0 1px 1px 0; +} + +.control-tooltip.is-visible { + visibility: visible; + opacity: 1; + transform: translateY(0); +} + +.control-section--palette { + gap: 14px; +} + +.palette-intro { + margin: 0; + color: var(--muted); + font-size: 10px; + line-height: 1.5; +} + +.palette-intro-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.palette-intro-row .palette-intro { + flex: 1; +} + +.palette-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.color-control { + min-width: 0; + padding: 10px; + display: grid; + gap: 8px; + border: 1px solid var(--control-line); + border-radius: 6px; + background: var(--bg-raised); + color: var(--ink-soft); + font-size: 10px; + transition: + border-color 160ms ease, + background-color 160ms ease; +} + +.color-control:hover { + border-color: var(--line-strong); + background: var(--surface); +} + +.color-control__picker { + position: relative; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.color-control__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.glow-toggle { + min-height: 22px; + padding: 0 6px; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid var(--control-line); + border-radius: 4px; + background: transparent; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + line-height: 1; + text-transform: uppercase; + cursor: pointer; +} + +.glow-toggle:hover, +.glow-toggle:focus-within { + border-color: var(--line-strong); + color: var(--ink-soft); +} + +.glow-toggle input { + width: 12px; + height: 12px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; +} + +.color-control--glow.is-glow-disabled .color-control__picker { + opacity: 0.48; +} + +.color-control--glow.is-glow-disabled .glow-toggle { + border-color: color-mix(in srgb, var(--accent), transparent 58%); + color: var(--accent-strong); +} + +.color-control--glow.is-glow-disabled .color-control__picker::after { + position: absolute; + top: 50%; + left: 0; + width: 34px; + height: 1px; + background: var(--faint); + content: ""; + pointer-events: none; + transform: rotate(-35deg); +} + +.color-control--glow.is-glow-disabled input[type="color"] { + cursor: not-allowed; + filter: grayscale(1); +} + +.color-control input[type="color"] { + width: 30px; + height: 24px; + flex: 0 0 auto; + padding: 2px; + border: 1px solid var(--control-line); + border-radius: 4px; + outline: 0; + background: transparent; + cursor: pointer; +} + +.color-control input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.color-control input[type="color"]::-webkit-color-swatch { + border: 0; + border-radius: 2px; +} + +.color-control input[type="color"]::-moz-color-swatch { + border: 0; + border-radius: 2px; +} + +.color-control input[type="color"]:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +.color-control output { + min-width: 0; + overflow: hidden; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; +} + +.palette-reset { + min-height: 34px; + padding: 0 11px; + justify-self: start; + border: 1px solid var(--control-line); + border-radius: 6px; + background: transparent; + color: var(--muted); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.07em; + text-transform: uppercase; + transition: + border-color 160ms ease, + color 160ms ease, + background-color 160ms ease; +} + +.palette-reset:hover { + border-color: var(--line-strong); + background: var(--surface); + color: var(--ink-soft); +} + +.control-section--palette.is-error-state .palette-intro::after { + display: block; + margin-top: 6px; + color: var(--danger); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + content: "ERROR PREVIEW PROTECTED"; +} + +.select-control, +.orb-boundary-control, +.orb-background-control, +.seed-control { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(132px, 1.15fr); + align-items: center; + gap: 15px; +} + +.select-control > span:first-child, +.orb-boundary-control > span:first-child, +.orb-background-control > span:first-child, +.seed-control > span:first-child { + color: var(--ink-soft); + font-size: 11px; + font-weight: 500; +} + +.select-control small, +.orb-boundary-control small, +.orb-background-control small, +.seed-control small { + display: block; + margin-top: 2px; + color: var(--faint); + font-size: 9px; + font-weight: 400; + line-height: 1.35; +} + +.orb-boundary-control { + cursor: pointer; + transition: opacity 160ms ease; +} + +.orb-boundary-toggle { + position: relative; + display: block; +} + +.orb-boundary-toggle input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + overflow: hidden; + opacity: 0; +} + +.orb-boundary-toggle__track { + position: relative; + min-height: 40px; + padding: 3px; + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + border: 1px solid var(--control-line); + border-radius: 7px; + background: var(--bg-raised); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ink), transparent 96%); + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + text-align: center; + text-transform: uppercase; + cursor: pointer; + transition: + border-color 160ms ease, + background-color 160ms ease; +} + +.orb-boundary-toggle__track::before { + position: absolute; + z-index: 0; + top: 3px; + bottom: 3px; + left: 3px; + width: calc(50% - 3px); + border: 1px solid color-mix(in srgb, var(--accent), transparent 56%); + border-radius: 4px; + background: var(--accent-wash); + content: ""; + transition: left 180ms ease; +} + +.orb-boundary-toggle__track > span { + position: relative; + z-index: 1; + transition: color 160ms ease; +} + +.orb-boundary-toggle input:not(:checked) + .orb-boundary-toggle__track > span:first-child, +.orb-boundary-toggle input:checked + .orb-boundary-toggle__track > span:last-child { + color: var(--accent-strong); +} + +.orb-boundary-toggle input:checked + .orb-boundary-toggle__track::before { + left: 50%; +} + +.orb-boundary-control:hover .orb-boundary-toggle__track { + border-color: var(--line-strong); + background: var(--surface); +} + +.orb-boundary-toggle input:focus-visible + .orb-boundary-toggle__track { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +.orb-boundary-control[data-available="false"] { + opacity: 0.5; + cursor: not-allowed; +} + +.orb-boundary-control[data-available="false"] .orb-boundary-toggle__track { + cursor: not-allowed; +} + +.orb-background-control { + transition: opacity 160ms ease; +} + +.orb-background-control[data-available="false"] { + opacity: 0.5; +} + +.orb-background-actions { + min-height: 40px; + padding: 6px 8px; + display: grid; + grid-template-columns: auto minmax(54px, 1fr) auto; + align-items: center; + gap: 6px; + border: 1px solid var(--control-line); + border-radius: 7px; + background: var(--bg-raised); + transition: + border-color 160ms ease, + background-color 160ms ease; +} + +.orb-background-mode { + min-width: 0; +} + +.orb-background-mode select { + width: 100%; + min-width: 0; + height: 24px; + padding: 0 18px 0 7px; + border: 1px solid var(--control-line); + border-radius: 4px; + outline: 0; + appearance: none; + background: + linear-gradient(45deg, transparent 50%, currentColor 50%) + calc(100% - 9px) 10px / 4px 4px no-repeat, + linear-gradient(135deg, currentColor 50%, transparent 50%) + calc(100% - 6px) 10px / 4px 4px no-repeat, + var(--bg-raised); + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.03em; + text-transform: uppercase; + cursor: pointer; +} + +.orb-background-mode select:hover, +.orb-background-mode select:focus-visible { + border-color: var(--line-strong); + color: var(--ink-soft); +} + +.orb-background-mode select:focus-visible { + box-shadow: 0 0 0 3px var(--accent-wash); +} + +.orb-background-mode select:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.orb-background-control[data-enabled="true"] .orb-background-actions, +.orb-background-control:hover .orb-background-actions { + border-color: var(--line-strong); + background: var(--surface); +} + +.color-enable-toggle { + min-height: 22px; + padding: 0 6px; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid var(--control-line); + border-radius: 4px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + line-height: 1; + text-transform: uppercase; + cursor: pointer; +} + +.color-enable-toggle:hover, +.color-enable-toggle:focus-within { + border-color: var(--line-strong); + color: var(--ink-soft); +} + +.color-enable-toggle input { + width: 12px; + height: 12px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; +} + +.orb-background-picker { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.orb-background-picker input[type="color"] { + width: 30px; + height: 24px; + padding: 2px; + border: 1px solid var(--control-line); + border-radius: 4px; + outline: 0; + background: transparent; + cursor: pointer; +} + +.orb-background-picker input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.orb-background-picker input[type="color"]::-webkit-color-swatch { + border: 0; + border-radius: 2px; +} + +.orb-background-picker input[type="color"]:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +.orb-background-picker input:disabled { + cursor: not-allowed; + filter: grayscale(1); + opacity: 0.48; +} + +.orb-background-picker output { + display: none; + min-width: 48px; + overflow: hidden; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; +} + +.select-shell { + position: relative; + display: block; +} + +.select-shell::after { + position: absolute; + top: 50%; + right: 12px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 10px; + content: "⌄"; + pointer-events: none; + transform: translateY(-58%); +} + +select, +.seed-control input { + width: 100%; + height: 40px; + border: 1px solid var(--control-line); + border-radius: 6px; + outline: 0; + background: var(--bg-raised); + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 10px; + transition: + border-color 160ms ease, + background-color 160ms ease, + box-shadow 160ms ease; +} + +select { + padding: 0 30px 0 11px; + appearance: none; +} + +select:hover, +.seed-control input:hover { + border-color: var(--line-strong); +} + +select:focus-visible, +.seed-control input:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +.range-control { + display: grid; + gap: 10px; +} + +.range-control > span { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--ink-soft); + font-size: 11px; + font-weight: 500; +} + +.range-control output { + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +input[type="range"] { + width: 100%; + height: 16px; + margin: 0; + padding: 0; + appearance: none; + outline: 0; + background: transparent; +} + +input[type="range"]::-webkit-slider-runnable-track { + height: 2px; + border-radius: 0; + background: linear-gradient(90deg, var(--accent), var(--control-line)); +} + +input[type="range"]::-webkit-slider-thumb { + width: 13px; + height: 13px; + margin-top: -5.5px; + border: 2px solid var(--surface); + border-radius: 2px; + appearance: none; + background: var(--ink); + box-shadow: 0 0 0 1px var(--line-strong); +} + +input[type="range"]::-moz-range-track { + height: 2px; + border: 0; + background: var(--control-line); +} + +input[type="range"]::-moz-range-progress { + height: 2px; + background: var(--accent); +} + +input[type="range"]::-moz-range-thumb { + width: 11px; + height: 11px; + border: 2px solid var(--surface); + border-radius: 2px; + background: var(--ink); + box-shadow: 0 0 0 1px var(--line-strong); +} + +input[type="range"]:focus-visible::-webkit-slider-thumb { + box-shadow: + 0 0 0 1px var(--accent), + 0 0 0 4px var(--accent-wash); +} + +.seed-control__row { + display: flex; + gap: 6px; +} + +.seed-control input { + min-width: 0; + padding: 0 11px; + cursor: text; +} + +.control-note { + padding: 18px 24px 21px; + display: flex; + align-items: flex-start; + gap: 10px; + background: color-mix(in srgb, var(--accent-wash), transparent 38%); +} + +.control-note__mark { + width: 7px; + height: 7px; + margin-top: 4px; + flex: 0 0 auto; + border: 1px solid var(--accent); + transform: rotate(45deg); +} + +.control-note p { + margin: 0; + color: var(--muted); + font-size: 10px; + line-height: 1.55; +} + +.usage-section { + margin: 0 0 clamp(92px, 10vw, 144px); + padding: clamp(88px, 9vw, 132px) 0; + border-block: 1px solid var(--line); +} + +.usage-section__heading { + display: grid; + grid-template-columns: minmax(140px, 0.34fr) minmax(0, 0.9fr) minmax(280px, 0.58fr); + align-items: end; + gap: clamp(32px, 5vw, 76px); +} + +.usage-section__heading > p:last-child { + max-width: 500px; + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.72; +} + +.usage-steps { + margin: clamp(46px, 6vw, 78px) 0 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + border: 1px solid var(--line); + background: var(--line); + list-style: none; +} + +.usage-step { + min-width: 0; + min-height: 430px; + padding: clamp(22px, 2.4vw, 34px); + display: grid; + grid-template-rows: auto 1fr; + gap: 66px; + background: + linear-gradient(145deg, var(--surface-sheen), transparent 46%), + var(--bg-raised); +} + +.usage-step > div { + min-width: 0; +} + +.usage-step__number { + width: fit-content; + padding-bottom: 6px; + border-bottom: 1px solid var(--line-strong); + color: var(--faint); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.12em; +} + +.usage-step h3 { + margin: 0; + color: var(--ink-soft); + font-size: 21px; + font-weight: 430; + letter-spacing: -0.025em; +} + +.usage-step p { + min-height: 42px; + margin: 10px 0 22px; + color: var(--muted); + font-size: 11px; + line-height: 1.62; +} + +.usage-step pre { + max-width: 100%; + margin: 8px 0 0; + padding: 14px; + overflow: auto; + border: 1px solid var(--code-line); + border-radius: 6px; + background: + linear-gradient(90deg, var(--code-grid), transparent 1px) 0 0 / 48px 100%, + var(--code-bg); + color: var(--code-ink); + scrollbar-color: var(--code-scrollbar) var(--code-bg); +} + +.usage-step pre:focus-visible { + border-color: var(--accent); + outline-offset: 2px; +} + +.usage-step code { + font-family: var(--font-mono); + font-size: 9px; + line-height: 1.72; + tab-size: 2; +} + +.library-section { + padding: 0 0 112px; +} + +.section-heading { + padding: 0 0 30px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 42px; +} + +.section-heading h2, +.api-copy h2 { + max-width: 760px; + margin: 13px 0 0; + color: var(--ink); + font-size: clamp(2rem, 4.2vw, 4.4rem); + font-weight: 280; + letter-spacing: -0.05em; + line-height: 1; +} + +.section-heading p:not(.section-index) { + max-width: 610px; + margin: 17px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.section-heading__aside { + min-width: 190px; + padding: 12px 0; + display: grid; + gap: 5px; + border-block: 1px solid var(--line); + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + text-align: right; + text-transform: uppercase; +} + +.section-heading__aside b { + color: var(--accent-strong); + font-size: 11px; +} + +.saved-presets { + margin: 0 0 24px; + padding: 17px; + border: 1px solid var(--line); + border-radius: 10px; + background: + linear-gradient(135deg, var(--surface-sheen), transparent 54%), + color-mix(in srgb, var(--bg-raised), transparent 12%); +} + +.saved-presets__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.saved-presets__header > div:first-child { + min-width: 0; +} + +.saved-presets__eyebrow { + margin: 0 0 5px; + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 7px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.saved-presets h3 { + margin: 0; + color: var(--ink-soft); + font-size: 15px; + font-weight: 540; + letter-spacing: -0.015em; +} + +.saved-presets__header p:not(.saved-presets__eyebrow) { + max-width: 650px; + margin: 5px 0 0; + color: var(--faint); + font-size: 9px; + line-height: 1.5; +} + +.saved-presets__actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; +} + +.saved-presets__count { + min-width: 54px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + text-align: right; + text-transform: uppercase; +} + +.saved-presets__empty { + min-height: 58px; + margin: 14px 0 0; + padding: 14px; + display: grid; + place-items: center; + border: 1px dashed var(--control-line); + border-radius: 7px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + line-height: 1.5; + text-align: center; +} + +.saved-preset-gallery { + max-height: 286px; + margin-top: 14px; + padding: 1px 3px 3px 1px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.saved-preset-card { + position: relative; + min-width: 0; + overflow: hidden; + border: 1px solid var(--control-line); + border-radius: 7px; + background: var(--surface); + transition: + border-color 160ms ease, + background-color 160ms ease, + transform 180ms var(--ease); +} + +.saved-preset-card:hover, +.saved-preset-card:focus-within { + border-color: var(--line-strong); + background: var(--surface-raised); +} + +.saved-preset-card:hover { + transform: translateY(-1px); +} + +.saved-preset-card__open { + width: 100%; + min-height: 76px; + padding: 11px 58px 11px 11px; + display: grid; + grid-template-columns: 52px minmax(0, 1fr); + align-items: center; + gap: 10px; + border: 0; + background: transparent; + color: inherit; + text-align: left; +} + +.saved-preset-card__open:focus-visible { + outline: 0; + box-shadow: inset 0 0 0 2px var(--accent); +} + +.saved-preset-card__preview { + width: 52px; + height: 52px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--control-line); + border-radius: 5px; + background: + linear-gradient(var(--canvas-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--canvas-grid) 1px, transparent 1px), + var(--canvas); + background-size: 8px 8px; +} + +.saved-preset-card__preview canvas { + width: 50px; + height: 50px; + display: block; + image-rendering: pixelated; +} + +.saved-preset-card__copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.saved-preset-card__copy strong, +.saved-preset-card__copy small, +.saved-preset-card__metadata { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.saved-preset-card__copy strong { + color: var(--ink-soft); + font-size: 10px; + font-weight: 560; +} + +.saved-preset-card__copy small, +.saved-preset-card__metadata { + color: var(--faint); + font-family: var(--font-mono); + font-size: 7px; + line-height: 1.35; +} + +.saved-preset-card__metadata { + color: var(--muted); +} + +.saved-preset-card__delete { + position: absolute; + top: 8px; + right: 8px; + min-height: 24px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--faint); + font-family: var(--font-mono); + font-size: 7px; + text-transform: uppercase; +} + +.saved-preset-card__delete:hover, +.saved-preset-card__delete:focus-visible { + border-color: color-mix(in srgb, var(--danger), transparent 62%); + background: color-mix(in srgb, var(--danger), transparent 92%); + color: var(--danger); +} + +.saved-presets__undo { + margin-top: 9px; + padding: 8px 10px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid var(--line); + color: var(--muted); + font-family: var(--font-mono); + font-size: 8px; +} + +.saved-presets__undo-button { + min-height: 28px; + padding: 0 8px; + border: 1px solid var(--accent); + border-radius: 4px; + background: var(--accent-wash); + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 8px; +} + +.sprite-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.sprite-card { + contain: layout paint; + position: relative; + min-width: 0; + min-height: 96px; + padding: 14px; + display: grid; + grid-template-columns: 66px minmax(0, 1fr); + align-items: center; + gap: 13px; + overflow: hidden; + border: 1px solid var(--control-line); + border-radius: 9px; + background: + linear-gradient(135deg, var(--surface-sheen), transparent 55%), + var(--surface); + color: var(--ink-soft); + text-align: left; + transition: + border-color 180ms ease, + background-color 180ms ease, + transform 220ms var(--ease), + box-shadow 220ms ease; +} + +.sprite-card::before { + position: absolute; + top: 9px; + right: 9px; + width: 5px; + height: 5px; + border: solid var(--line-strong); + border-width: 1px 1px 0 0; + content: ""; +} + +.sprite-card::after { + position: absolute; + right: 12px; + bottom: 10px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + content: "+"; + opacity: 0; + transition: opacity 160ms ease; +} + +.sprite-card:hover { + z-index: 1; + border-color: var(--line-strong); + background: var(--surface-raised); + box-shadow: var(--shadow-card); + transform: translateY(-2px); +} + +.sprite-card:hover::after { + opacity: 1; +} + +.sprite-card.is-selected { + border-color: color-mix(in srgb, var(--accent), var(--line) 20%); + background: + linear-gradient(135deg, var(--accent-wash), transparent 58%), + var(--surface-raised); + box-shadow: + inset 2px 0 var(--accent), + var(--shadow-card-selected); +} + +.sprite-card.is-selected::after { + color: var(--accent-strong); + content: "SELECTED"; + opacity: 1; +} + +.sprite-preview { + contain: paint; + position: relative; + width: 66px; + height: 66px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--control-line); + border-radius: 6px; + background: + linear-gradient(var(--canvas-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--canvas-grid) 1px, transparent 1px), + var(--canvas); + background-size: 11px 11px; +} + +.sprite-preview::before, +.sprite-preview::after { + position: absolute; + z-index: 2; + width: 8px; + height: 8px; + content: ""; + pointer-events: none; +} + +.sprite-preview::before { + top: 4px; + left: 4px; + border-top: 1px solid var(--line-strong); + border-left: 1px solid var(--line-strong); +} + +.sprite-preview::after { + right: 4px; + bottom: 4px; + border-right: 1px solid var(--line-strong); + border-bottom: 1px solid var(--line-strong); +} + +.sprite-preview canvas { + position: relative; + z-index: 1; + width: 64px; + height: 64px; + display: block; + image-rendering: pixelated; +} + +.sprite-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.sprite-copy strong { + overflow: hidden; + color: var(--ink-soft); + font-size: 11px; + font-weight: 540; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sprite-copy small { + overflow: hidden; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.025em; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sprite-card.is-selected .sprite-copy strong, +.sprite-card.is-selected .sprite-copy small { + color: var(--accent-strong); +} + +.api-section { + margin-bottom: 100px; + display: grid; + grid-template-columns: minmax(310px, 0.75fr) minmax(0, 1.25fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow); +} + +.api-copy { + padding: clamp(32px, 5vw, 72px); + display: flex; + flex-direction: column; + align-items: flex-start; + border-right: 1px solid var(--line); +} + +.api-copy h2 { + max-width: 620px; + font-size: clamp(2.5rem, 4.2vw, 4.8rem); +} + +.api-copy > p:not(.section-index) { + max-width: 590px; + margin: 23px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.7; +} + +.api-facts { + width: 100%; + margin: 34px 0 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + border: 1px solid var(--line); + background: var(--line); +} + +.api-facts div { + min-width: 0; + padding: 14px; + background: var(--bg-raised); +} + +.api-facts dt { + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.api-facts dd { + margin: 7px 0 0; + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 9px; + line-height: 1.45; +} + +.export-actions { + margin-top: auto; + padding-top: 38px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.code-panel { + min-width: 0; + min-height: 620px; + display: grid; + grid-template-rows: 50px minmax(0, 1fr); + background: var(--code-bg); +} + +.code-panel__header { + padding: 0 17px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--code-line); + color: var(--code-muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.06em; +} + +.code-panel__header > div { + display: flex; + align-items: center; + gap: 12px; +} + +.code-lights { + display: flex; + gap: 4px; +} + +.code-lights i { + width: 5px; + height: 5px; + display: block; + border-radius: 50%; + background: var(--code-dot-1); +} + +.code-lights i:nth-child(2) { + background: var(--code-dot-2); +} + +.code-lights i:nth-child(3) { + background: var(--code-dot-3); +} + +.code-copy { + min-height: 30px; + padding: 0 9px; + border: 1px solid var(--code-control); + border-radius: 5px; + background: transparent; + color: var(--code-control-ink); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.code-copy:hover { + border-color: var(--code-line-strong); + background: var(--code-hover); + color: var(--code-ink-strong); +} + +.code-panel pre { + min-width: 0; + max-width: 100%; + margin: 0; + padding: clamp(24px, 4vw, 52px); + overflow: auto; + background: + linear-gradient(90deg, var(--code-grid), transparent 1px) 0 0 / 72px 100%, + transparent; + scrollbar-color: var(--code-scrollbar) var(--code-bg); +} + +.code-panel code { + color: var(--code-ink); + font-family: var(--font-mono); + font-size: clamp(10px, 0.9vw, 13px); + line-height: 1.85; + tab-size: 2; +} + +.download-section { + margin-bottom: 100px; + display: grid; + grid-template-columns: minmax(310px, 0.75fr) minmax(0, 1.25fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: + linear-gradient(135deg, var(--surface-sheen), transparent 38%), + var(--surface); + box-shadow: var(--shadow); +} + +.download-section__copy { + min-width: 0; + padding: clamp(32px, 5vw, 72px); + display: flex; + flex-direction: column; + align-items: flex-start; + border-right: 1px solid var(--line); +} + +.download-section__copy h2 { + max-width: 620px; + margin: 13px 0 0; + color: var(--ink); + font-size: clamp(2.5rem, 4.2vw, 4.8rem); + font-weight: 280; + letter-spacing: -0.05em; + line-height: 1; +} + +.download-section__copy > p:not(.section-index):not(.download-license-note) { + max-width: 590px; + margin: 23px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.7; +} + +.download-card__contents { + width: 100%; + margin: 32px 0 0; + padding: 0; + display: grid; + border-top: 1px solid var(--line); + list-style: none; +} + +.download-card__contents li { + position: relative; + min-height: 38px; + padding: 11px 0 10px 18px; + border-bottom: 1px solid var(--line); + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 9px; + line-height: 1.55; +} + +.download-card__contents li::before { + position: absolute; + top: 15px; + left: 1px; + width: 6px; + height: 6px; + border: 1px solid var(--faint); + content: ""; + transform: rotate(45deg); +} + +.download-license-note { + margin: auto 0 0; + padding-top: 32px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + line-height: 1.6; +} + +.download-license-note strong { + color: var(--muted); + font-weight: 600; +} + +.download-options { + min-width: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: var(--line); +} + +.download-card { + min-width: 0; + min-height: 310px; + padding: clamp(24px, 3.2vw, 42px); + display: flex; + flex-direction: column; + align-items: flex-start; + background: + linear-gradient(145deg, var(--surface-sheen), transparent 52%), + var(--bg-raised); +} + +.download-card--primary { + min-height: 340px; + grid-column: 1 / -1; + background: + linear-gradient(145deg, var(--accent-wash), transparent 55%), + var(--bg-raised); +} + +.download-card__heading { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.download-card__heading p { + margin: 0; +} + +.download-card__heading span { + padding: 4px 6px; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: var(--muted); +} + +.download-card h3 { + margin: 38px 0 0; + color: var(--ink); + font-size: clamp(1.55rem, 2.5vw, 2.5rem); + font-weight: 340; + letter-spacing: -0.035em; + line-height: 1.05; +} + +.download-card > p:not(.download-card__meta) { + max-width: 600px; + margin: 13px 0 0; + color: var(--muted); + font-size: 11px; + line-height: 1.65; +} + +.download-card__meta { + margin: 21px 0 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.download-card__meta span { + padding: 5px 7px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 8px; + line-height: 1; + text-transform: uppercase; +} + +.download-card__actions { + width: 100%; + margin-top: auto; + padding-top: 30px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.download-card__actions .button { + min-height: 44px; +} + +.download-inline-link { + min-height: 44px; + padding-block: 8px; + display: inline-flex; + align-items: center; + border-bottom: 1px solid var(--line-strong); + color: var(--muted); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + text-decoration: none; + text-transform: uppercase; + transition: + border-color 160ms ease, + color 160ms ease; +} + +.download-inline-link:hover, +.download-inline-link:focus-visible { + border-color: var(--ink); + color: var(--ink); +} + +.site-footer { + width: var(--content); + min-height: 86px; + margin-inline: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-top: 1px solid var(--line); + color: var(--faint); + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.site-footer p { + margin: 0; +} + +.site-footer p span { + margin-left: 12px; + color: var(--muted); +} + +.site-footer a { + color: var(--muted); + text-decoration: none; +} + +.site-footer a:hover { + color: var(--ink); +} + +.site-footer__links { + display: flex; + align-items: center; + gap: 18px; +} + +.export-dialog { + width: min(920px, calc(100vw - 32px)); + max-height: min(760px, calc(100dvh - 32px)); + padding: 0; + overflow: auto; + border: 1px solid var(--line-strong); + border-radius: 14px; + background: var(--surface); + box-shadow: var(--shadow-dialog); + color: var(--ink); +} + +.export-dialog::backdrop { + background: var(--backdrop); + backdrop-filter: blur(8px); +} + +.export-dialog__surface { + margin: 0; +} + +.export-dialog__header { + position: sticky; + z-index: 2; + top: 0; + padding: 22px 24px 18px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface), transparent 4%); + backdrop-filter: blur(18px); +} + +.export-dialog__header h2 { + margin: 8px 0 0; + font-size: 24px; + font-weight: 440; + letter-spacing: -0.025em; +} + +.dialog-close { + width: 38px; + height: 38px; + flex: 0 0 auto; + border: 1px solid var(--control-line); + border-radius: 7px; + background: transparent; + color: var(--muted); + font-size: 22px; + line-height: 1; +} + +.dialog-close:hover { + border-color: var(--line-strong); + background: var(--surface-hover); + color: var(--ink); +} + +.export-dialog__body { + padding: 24px; + display: grid; + grid-template-columns: minmax(250px, 0.88fr) minmax(300px, 1.12fr); + gap: 24px; +} + +.export-preview { + min-width: 0; + display: grid; + align-content: start; + gap: 15px; +} + +.export-preview__frame { + position: relative; + width: min(100%, 340px); + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: 9px; + background: var(--canvas); +} + +.export-preview__frame.is-transparent { + background-color: var(--checker-base); + background-image: + linear-gradient(45deg, var(--checker-tile) 25%, transparent 25%), + linear-gradient(-45deg, var(--checker-tile) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--checker-tile) 75%), + linear-gradient(-45deg, transparent 75%, var(--checker-tile) 75%); + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; + background-size: 16px 16px; +} + +.export-preview canvas { + width: 100%; + height: 100%; + display: block; + image-rendering: pixelated; +} + +.export-preview h3 { + margin: 0; + color: var(--ink-soft); + font-size: 12px; + font-weight: 550; +} + +.export-preview p { + margin: 6px 0 0; + color: var(--muted); + font-size: 10px; + line-height: 1.55; +} + +.export-settings { + min-width: 0; + display: grid; + align-content: start; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.export-field--filename, +.export-check, +.export-summary { + grid-column: 1 / -1; +} + +.export-field input, +.export-field select { + height: 42px; + background: var(--bg-raised); + font-size: 10px; +} + +.export-field select:disabled, +.export-check input:disabled + span { + cursor: not-allowed; + opacity: 0.5; +} + +.export-check { + min-height: 42px; + padding: 0 12px; + display: flex; + align-items: center; + gap: 9px; + border: 1px solid var(--control-line); + border-radius: 6px; + background: var(--bg-raised); + color: var(--ink-soft); + font-size: 10px; +} + +.export-check input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); +} + +.export-summary { + margin: 2px 0 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--line); +} + +.export-summary div { + min-width: 0; + padding: 12px; + background: var(--bg-raised); +} + +.export-summary dt { + color: var(--faint); + font-family: var(--font-mono); + font-size: 7px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.export-summary dd { + margin: 5px 0 0; + overflow: hidden; + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-dialog__actions { + position: sticky; + z-index: 2; + bottom: 0; + padding: 16px 24px; + display: flex; + justify-content: flex-end; + gap: 8px; + border-top: 1px solid var(--line); + background: color-mix(in srgb, var(--surface), transparent 4%); + backdrop-filter: blur(18px); +} + +.export-dialog__actions .button:disabled { + cursor: not-allowed; +} + +@keyframes status-pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--success), transparent 68%); + } + 45%, + 100% { + box-shadow: 0 0 0 7px color-mix(in srgb, var(--success), transparent 100%); + } +} + +@keyframes field-scan { + from { + transform: translateY(0); + } + to { + transform: translateY(760%); + } +} + +@media (max-width: 1240px) { + :root { + --content: min(calc(100% - 40px), 1180px); + } + + .site-nav { + display: none; + } + + .workbench { + grid-template-columns: minmax(0, 1fr) 340px; + } + + .sprite-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .saved-preset-gallery { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .stage-lower { + align-items: flex-start; + flex-direction: column; + } + + .stage-metrics { + width: 100%; + justify-content: flex-start; + text-align: left; + } + + .workflow-bar { + grid-template-columns: 1fr; + } + + .recipe-workflow { + justify-items: stretch; + } + + .recipe-workflow__status, + .recipe-workflow__actions { + justify-content: flex-start; + } +} + +@media (min-width: 1021px) and (max-height: 1040px) { + .control-panel { + position: static; + } +} + +@media (max-width: 1020px) { + .site-nav { + display: none; + } + + .hero { + min-height: auto; + padding-top: 76px; + grid-template-columns: 1fr; + gap: 58px; + } + + .landing-hero__copy { + max-width: 820px; + } + + .landing-hero__visual { + width: min(100%, 720px); + min-height: 540px; + } + + .principles-section__heading, + .usage-section__heading { + grid-template-columns: 1fr; + gap: 20px; + } + + .principles-section__heading h2, + .usage-section__heading h2 { + max-width: 850px; + } + + .usage-section__heading > p:last-child { + max-width: 650px; + } + + .studio-intro { + grid-template-columns: 1fr; + gap: 24px; + } + + .studio-intro > p { + max-width: 680px; + } + + .workbench { + grid-template-columns: 1fr; + } + + .control-panel { + position: static; + } + + .control-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .control-section { + border-right: 1px solid var(--line); + } + + .control-section:nth-of-type(even) { + border-right: 0; + } + + .control-note { + border-top: 0; + } + + .api-section { + grid-template-columns: 1fr; + } + + .download-section { + grid-template-columns: 1fr; + } + + .api-copy { + min-height: 480px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .download-section__copy { + min-height: 520px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .code-panel { + min-height: 560px; + } +} + +@media (max-width: 760px) { + :root { + --content: calc(100% - 28px); + --radius-lg: 13px; + } + + .topbar__inner { + min-height: 62px; + } + + .brand__copy small, + .version-pill, + .runtime-status { + display: none; + } + + .topbar__actions { + gap: 5px; + } + + .button { + min-height: 40px; + padding-inline: 11px; + font-size: 9px; + } + + .theme-button { + width: 40px; + padding: 0; + overflow: hidden; + color: transparent; + white-space: nowrap; + } + + .theme-button::before { + flex: 0 0 auto; + color: var(--ink-soft); + } + + .hero { + min-height: auto; + padding: 64px 0 60px; + gap: 44px; + } + + .hero h1 { + font-size: clamp(3rem, 15vw, 5.5rem); + letter-spacing: -0.07em; + } + + .hero__lede { + margin-top: 22px; + font-size: 14px; + } + + .landing-hero__actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .landing-hero__actions .button { + width: 100%; + padding-inline: 10px; + } + + .landing-hero__visual { + --hero-proof-height: 82px; + --hero-proof-offset: 41px; + min-height: 430px; + } + + .hero-engine-output { + width: min(76%, 330px); + } + + .hero-proof { + min-height: 82px; + } + + .hero-proof div { + padding: 14px; + } + + .principles-section, + .usage-section { + padding-block: 78px; + } + + .principles-section__heading h2, + .studio-intro h2, + .usage-section__heading h2 { + font-size: clamp(2.6rem, 12vw, 4.2rem); + } + + .principles-grid, + .usage-steps { + grid-template-columns: 1fr; + } + + .principle-card { + min-height: 210px; + padding: 24px; + } + + .principle-card h3 { + margin-top: 45px; + } + + .studio-intro { + padding-top: 76px; + } + + .usage-section { + margin-bottom: 82px; + } + + .usage-step { + min-height: auto; + gap: 48px; + } + + .usage-step p { + min-height: 0; + } + + .workbench { + padding-top: 14px; + padding-bottom: 82px; + } + + .stage-panel { + padding: 16px; + } + + .stage-heading { + min-height: 0; + padding: 4px 1px 17px; + display: grid; + } + + .state-description { + min-height: 0; + } + + .signal-cluster { + width: 100%; + margin-top: 7px; + text-align: left; + } + + .signal-cluster__buttons { + width: 100%; + } + + .signal-button { + min-height: 40px; + flex: 1; + } + + #openRecipeButton, + #importButton { + width: 40px; + padding: 0; + overflow: hidden; + color: transparent; + white-space: nowrap; + } + + #openRecipeButton::before, + #importButton::before { + display: grid; + place-items: center; + color: var(--ink-soft); + font-size: 15px; + } + + #openRecipeButton::before { + content: "↥"; + } + + #importButton::before { + content: "+"; + } + + .workflow-bar { + padding: 10px; + } + + .saved-presets__header { + align-items: flex-start; + } + + .saved-preset-gallery { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .recipe-workflow__actions { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(4, auto); + } + + .recent-preset { + width: auto; + max-width: none; + } + + .workflow-button, + .workflow-export, + .recent-preset select { + min-height: 40px; + } + + .frame-badge--bottom { + display: none; + } + + .progress-control, + .progress-control.is-visible { + grid-template-columns: 1fr; + gap: 10px; + } + + .stage-lower { + padding-bottom: 16px; + } + + .quick-states { + width: 100%; + } + + .quick-states > div { + display: grid; + grid-template-columns: repeat(4, 1fr); + } + + .quick-states button { + min-height: 36px; + padding-inline: 6px; + } + + .stage-metrics { + gap: 17px; + } + + .stage-metrics__state { + margin-left: auto; + } + + .stage-actions { + align-items: stretch; + flex-direction: column; + } + + .stage-actions p { + display: none; + } + + .stage-actions > div { + display: grid; + grid-template-columns: 0.8fr 1.2fr; + } + + .stage-actions .button { + min-height: 44px; + } + + .control-heading { + padding: 21px 18px; + } + + .control-form { + display: block; + } + + .control-section { + padding: 20px 18px 22px; + border-right: 0; + } + + .control-section legend { + padding: 17px 18px 0; + } + + .select-control, + .orb-boundary-control, + .orb-background-control, + .seed-control { + grid-template-columns: minmax(0, 0.85fr) minmax(140px, 1.15fr); + } + + .control-note { + padding-inline: 18px; + } + + .section-heading { + align-items: flex-start; + flex-direction: column; + gap: 22px; + } + + .section-heading h2, + .api-copy h2, + .download-section__copy h2 { + font-size: clamp(2.6rem, 12vw, 4.2rem); + } + + .section-heading__aside { + width: 100%; + min-width: 0; + text-align: left; + } + + .sprite-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sprite-card { + min-height: 132px; + padding: 12px; + grid-template-columns: 1fr; + justify-items: start; + } + + .sprite-preview, + .sprite-preview canvas { + width: 58px; + height: 58px; + } + + .library-section { + padding-bottom: 82px; + } + + .api-section { + margin-bottom: 72px; + } + + .download-section { + margin-bottom: 72px; + } + + .api-copy { + min-height: 510px; + padding: 34px 22px; + } + + .api-facts { + grid-template-columns: 1fr; + } + + .api-facts div { + display: flex; + justify-content: space-between; + gap: 18px; + } + + .api-facts dd { + margin: 0; + text-align: right; + } + + .export-actions { + width: 100%; + } + + .export-actions .button { + min-height: 44px; + flex: 1; + } + + .download-section__copy { + min-height: auto; + padding: 34px 22px; + } + + .download-license-note { + margin-top: 34px; + } + + .download-options { + grid-template-columns: 1fr; + } + + .download-card, + .download-card--primary { + min-height: 300px; + grid-column: auto; + } + + .download-card__actions .button { + width: 100%; + } + + .export-dialog__body { + grid-template-columns: 1fr; + } + + .export-preview { + grid-template-columns: minmax(150px, 0.55fr) minmax(180px, 1fr); + align-items: center; + } + + .export-preview__frame { + width: 100%; + } + + .code-panel { + min-height: 500px; + } + + .site-footer { + min-height: 100px; + } + + .site-footer p span { + display: none; + } +} + +@media (max-width: 430px) { + .brand { + gap: 7px; + } + + .brand__mark { + width: 34px; + height: 34px; + } + + .topbar__actions .button { + padding-inline: 8px; + letter-spacing: 0; + } + + .landing-hero__actions { + grid-template-columns: 1fr; + } + + .landing-hero__visual { + --hero-proof-height: 76px; + --hero-proof-offset: 38px; + min-height: 350px; + } + + .landing-hero__visual::before, + .landing-hero__visual::after { + top: 13px; + font-size: 7px; + } + + .landing-hero__visual::before { + left: 13px; + } + + .landing-hero__visual::after { + right: 13px; + } + + .hero-engine-output { + width: min(74%, 250px); + } + + .hero-proof { + min-height: 76px; + } + + .hero-proof div { + padding: 11px 9px; + } + + .hero-proof dt { + font-size: 15px; + } + + .hero-proof dd { + font-size: 7px; + white-space: normal; + } + + .state-heading { + align-items: flex-start; + flex-direction: column; + gap: 7px; + } + + .canvas-frame { + border-radius: 7px; + } + + .frame-badge--top { + top: 12px; + left: 13px; + } + + .preview-scale-control { + top: 9px; + right: 9px; + } + + .preview-scale-control button { + min-width: 40px; + min-height: 34px; + padding-inline: 7px; + } + + .state-picker { + grid-template-columns: 1fr; + } + + .saved-presets { + padding: 14px; + } + + .saved-presets__header { + flex-direction: column; + gap: 12px; + } + + .saved-presets__actions { + width: 100%; + justify-content: space-between; + } + + .saved-preset-gallery { + grid-template-columns: 1fr; + } + + .recipe-workflow__status { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } + + #workflowStatus { + max-width: 100%; + } + + .recipe-workflow__actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .recent-preset, + .workflow-export { + grid-column: 1 / -1; + } + + .workflow-button, + .workflow-export, + .recent-preset select { + width: 100%; + min-height: 44px; + } + + .quick-states > div { + grid-template-columns: repeat(2, 1fr); + } + + .stage-metrics { + display: grid; + grid-template-columns: minmax(0, 0.5fr) minmax(0, 0.5fr) minmax(0, 1.3fr); + } + + .stage-metrics > div, + .stage-metrics__state { + min-width: 0 !important; + } + + .stage-metrics__state { + margin-left: 0; + } + + .stage-metrics dd { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .select-control, + .orb-boundary-control, + .orb-background-control, + .seed-control { + grid-template-columns: 1fr; + gap: 8px; + } + + .sprite-grid { + grid-template-columns: 1fr; + } + + .sprite-card { + min-height: 88px; + grid-template-columns: 62px minmax(0, 1fr); + } + + .export-dialog { + width: calc(100vw - 18px); + max-height: calc(100dvh - 18px); + border-radius: 10px; + } + + .export-dialog__header, + .export-dialog__body, + .export-dialog__actions { + padding-inline: 16px; + } + + .export-preview { + grid-template-columns: 1fr; + } + + .export-preview__frame { + max-width: 260px; + } + + .export-settings, + .export-summary { + grid-template-columns: 1fr; + } + + .export-summary div { + grid-column: 1; + } + + .site-footer { + align-items: flex-start; + justify-content: center; + flex-direction: column; + gap: 7px; + } + + .download-card { + min-height: 280px; + padding: 24px 20px; + } + + .download-card__meta, + .download-card__actions { + align-items: flex-start; + flex-direction: column; + } + + .site-footer__links { + width: 100%; + justify-content: space-between; + } +} + +@media (pointer: coarse) { + .preview-scale-control button, + .quick-states button, + .orb-boundary-toggle__track, + .glow-toggle, + .color-enable-toggle, + .orb-background-mode select, + .orb-background-picker input[type="color"], + .color-control input[type="color"], + .workflow-button, + .state-picker input, + .state-picker select, + .recent-preset select { + min-height: 44px; + } + + .saved-preset-card__open, + .saved-preset-card__delete, + .saved-presets__undo-button { + min-height: 44px; + } + + .color-control input[type="color"], + .orb-background-picker input[type="color"] { + width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .canvas-frame::after { + display: none; + } +} diff --git a/web/vendor/src/web-component-register.js b/web/vendor/src/web-component-register.js new file mode 100644 index 0000000..71d4df8 --- /dev/null +++ b/web/vendor/src/web-component-register.js @@ -0,0 +1,9 @@ +import JoanGlyphElement, { + JOAN_GLYPH_TAG_NAME, + defineJoanGlyph, +} from "./web-component.js"; + +defineJoanGlyph(JOAN_GLYPH_TAG_NAME); + +export { JOAN_GLYPH_TAG_NAME, JoanGlyphElement, defineJoanGlyph }; +export default JoanGlyphElement; diff --git a/web/vendor/src/web-component.js b/web/vendor/src/web-component.js new file mode 100644 index 0000000..4f225b9 --- /dev/null +++ b/web/vendor/src/web-component.js @@ -0,0 +1,308 @@ +import JoanGlyphEngine from "./joan-engine.js"; + +export const JOAN_GLYPH_TAG_NAME = "joan-glyph"; + +const observed = [ + "sprite", + "seed", + "resolution", + "speed", + "density", + "field", + "pixel-shape", + "pixel-switch", + "orb-boundary", + "orb-background-color", + "orb-background-mode", + "non-error-background", + "non-error-off", + "non-error-ink", + "non-error-accent", + "non-error-glow", + "noninteractive", + "paused", + "transparent", +]; + +const paletteAttributes = { + "non-error-background": "background", + "non-error-off": "off", + "non-error-ink": "ink", + "non-error-accent": "accent", + "non-error-glow": "glow", +}; + +// Keeping the module evaluable without DOM globals lets SSR builds inspect and +// bundle it. Instances are still browser elements and should only be created +// after a real HTMLElement implementation exists. +const HTMLElementBase = + typeof globalThis.HTMLElement === "undefined" + ? class JoanGlyphSsrBase extends EventTarget {} + : globalThis.HTMLElement; + +export class JoanGlyphElement extends HTMLElementBase { + static observedAttributes = observed; + + constructor() { + super(); + const root = this.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = ` + :host { + display: inline-block; + inline-size: 68px; + block-size: 68px; + aspect-ratio: 1; + contain: layout paint style; + } + canvas { + display: block; + inline-size: 100%; + block-size: 100%; + image-rendering: pixelated; + touch-action: none; + } + `; + this.canvas = document.createElement("canvas"); + root.append(style, this.canvas); + } + + connectedCallback() { + if (this._engine) return; + this._engine = new JoanGlyphEngine(this.canvas, this.readOptions()); + } + + disconnectedCallback() { + this._engine?.destroy(); + this._engine = null; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this._engine || oldValue === newValue) return; + switch (name) { + case "sprite": + this._engine.setSprite(newValue || "ai.idle"); + break; + case "seed": + this._engine.setSeed(newValue || "joan-v5"); + break; + case "resolution": + this._engine.setResolution(Number(newValue) || 68); + break; + case "field": + if (newValue === null) this._engine.useRecipeFields(); + else this._engine.setField(newValue); + break; + case "pixel-shape": + if (newValue === null) this._engine.setOptions({ pixelShape: null }); + else this._engine.setPixelShape(newValue); + break; + case "pixel-switch": + if (newValue === null) this._engine.setOptions({ pixelSwitch: null }); + else this._engine.setPixelSwitch(newValue); + break; + case "orb-boundary": + this._engine.setOptions({ + orbBoundary: + String(newValue || "").trim().toLowerCase() === "gestalt" + ? "gestalt" + : "defined", + }); + break; + case "orb-background-color": + case "orb-background-mode": + this._engine.setOptions(this.readOrbBackground()); + break; + case "non-error-background": + case "non-error-off": + case "non-error-ink": + case "non-error-accent": + case "non-error-glow": + this._engine.setNonErrorPalette(this.readNonErrorPalette()); + break; + case "paused": + if (newValue === null) this._engine.play(); + else this._engine.pause(); + break; + case "noninteractive": + this._engine.setOptions({ interactive: newValue === null }); + break; + case "transparent": + this._engine.setOptions({ background: newValue === null }); + break; + case "speed": + this._engine.setOptions({ speed: Number(newValue) || 1 }); + break; + case "density": + this._engine.setOptions({ density: Number(newValue) || 1 }); + break; + default: + break; + } + } + + readOptions() { + return { + sprite: this.getAttribute("sprite") || "ai.idle", + seed: this.getAttribute("seed") || "joan-v5", + gridSize: Number(this.getAttribute("resolution")) || 68, + speed: Number(this.getAttribute("speed")) || 1, + density: Number(this.getAttribute("density")) || 1, + field: this.getAttribute("field") || undefined, + pixelShape: this.getAttribute("pixel-shape") || undefined, + pixelSwitch: this.getAttribute("pixel-switch") || undefined, + orbBoundary: + String(this.getAttribute("orb-boundary") || "").trim().toLowerCase() === + "gestalt" + ? "gestalt" + : "defined", + ...this.readOrbBackground(), + nonErrorPalette: this.readNonErrorPalette(), + autoplay: !this.hasAttribute("paused"), + interactive: !this.hasAttribute("noninteractive"), + background: !this.hasAttribute("transparent"), + }; + } + + readOrbBackground() { + const colorAttribute = this.getAttribute("orb-background-color")?.trim(); + const orbBackgroundColor = + colorAttribute && colorAttribute.toLowerCase() !== "transparent" + ? colorAttribute + : null; + const modeAttribute = this.getAttribute("orb-background-mode"); + const requestedMode = String(modeAttribute || "").trim().toLowerCase(); + const orbBackgroundMode = + modeAttribute === null + ? orbBackgroundColor + ? "solid" + : "none" + : ["none", "solid", "pixelated"].includes(requestedMode) + ? requestedMode + : "none"; + + return { orbBackgroundColor, orbBackgroundMode }; + } + + readNonErrorPalette() { + const palette = {}; + for (const [attribute, key] of Object.entries(paletteAttributes)) { + const value = this.getAttribute(attribute)?.trim(); + if (value) palette[key] = value; + } + return Object.keys(palette).length ? palette : null; + } + + get engine() { + return this._engine; + } + + setSprite(sprite, options) { + return this._engine?.setSprite(sprite, options); + } + + setProgress(value) { + return this._engine?.setProgress(value); + } + + setNonErrorPalette(palette) { + return this._engine?.setNonErrorPalette(palette); + } + + setSeed(seed) { + return this._engine?.setSeed(seed); + } + + setResolution(size) { + return this._engine?.setResolution(size); + } + + setField(field) { + return this._engine?.setField(field); + } + + useRecipeFields() { + return this._engine?.useRecipeFields(); + } + + setPixelShape(shape) { + return this._engine?.setPixelShape(shape); + } + + setPixelSwitch(pixelSwitch) { + return this._engine?.setPixelSwitch(pixelSwitch); + } + + setOptions(options) { + return this._engine?.setOptions(options); + } + + configure(options, configureOptions) { + return this._engine?.configure(options, configureOptions); + } + + setAudioLevel(value) { + return this._engine?.setAudioLevel(value); + } + + activate(options) { + return this._engine?.activate(options); + } + + resume(options) { + return this._engine?.resume(options); + } + + play() { + return this._engine?.play(); + } + + pause() { + return this._engine?.pause(); + } + + toggle() { + return this._engine?.toggle(); + } + + renderOnce(time) { + return this._engine?.renderOnce(time); + } + + transitionTo(sprite, options) { + return this._engine?.transitionTo(sprite, options); + } + + whenTransitionComplete(options) { + return this._engine?.whenTransitionComplete(options); + } + + inspect() { + return this._engine?.inspect(); + } + + on(type, listener, options) { + return this._engine?.on(type, listener, options); + } + + exportConfig() { + return this._engine?.exportConfig(); + } + + signal(type, payload) { + return this._engine?.signal(type, payload); + } +} + +export function defineJoanGlyph(tagName = JOAN_GLYPH_TAG_NAME) { + const registry = globalThis.customElements; + if (!registry) return JoanGlyphElement; + if (!registry.get(tagName)) registry.define(tagName, JoanGlyphElement); + return JoanGlyphElement; +} + +// Preserve the original import-and-register behavior. Applications that prefer +// an explicit side-effect entry can import `web-component/register` instead. +if (typeof globalThis.customElements !== "undefined") defineJoanGlyph(); + +export default JoanGlyphElement; diff --git a/web/vendor/types/config.d.ts b/web/vendor/types/config.d.ts new file mode 100644 index 0000000..44d1523 --- /dev/null +++ b/web/vendor/types/config.d.ts @@ -0,0 +1,86 @@ +import type { + BuiltInSpriteId, + FieldId, + JoanEngineOptions, + PixelShape, + PixelSwitch, + SpriteRecipe, + Transition, +} from "./index.js"; + +export interface ConfigurationIssue { + code: string; + message: string; + path: string | null; + value: unknown; + suggestion: string | null; + severity: "error"; +} + +export interface InspectEngineOptionsResult { + ok: boolean; + value: Partial & Record; + issues: ConfigurationIssue[]; +} + +export interface InspectEngineOptionsSettings { + coerce?: boolean; + allowUnknown?: boolean; +} + +export interface ValidateEngineOptionsSettings + extends InspectEngineOptionsSettings { + strict?: boolean; +} + +export class JoanConfigurationError extends RangeError { + readonly name: "JoanConfigurationError"; + readonly code: string; + readonly path: string | null; + readonly value: unknown; + readonly suggestion: string | null; + readonly allowed: unknown[] | null; + constructor( + message: string, + details?: Partial & { allowed?: readonly unknown[] }, + ); + toIssue(): ConfigurationIssue; +} + +export const TRANSITION_IDS: readonly Transition[]; +export const PIXEL_SWITCH_IDS: readonly PixelSwitch[]; +export const PIXEL_SHAPE_IDS: readonly PixelShape[]; + +export function nearestName( + value: unknown, + candidates: readonly T[], + options?: { maxDistance?: number }, +): T | null; + +export function validateKnownValue( + value: unknown, + allowed: readonly T[], + options?: { + aliases?: Readonly>; + label?: string; + path?: string; + }, +): T; + +export function validateSpriteId(value: unknown): BuiltInSpriteId; +export function validateFieldId(value: unknown): FieldId; +export function validateTransition(value: unknown): Transition; +export function validatePixelShape(value: unknown): PixelShape; +export function validatePixelSwitch(value: unknown): PixelSwitch; +export function inspectEngineOptions( + input: unknown, + options?: InspectEngineOptionsSettings, +): InspectEngineOptionsResult; +export function validateEngineOptions>( + input: T, + options?: ValidateEngineOptionsSettings, +): T & JoanEngineOptions; +export function validateRecipe(input: unknown): SpriteRecipe; +export function defineRecipe( + recipe: T, +): Readonly; diff --git a/web/vendor/types/fields.d.ts b/web/vendor/types/fields.d.ts new file mode 100644 index 0000000..d30f55b --- /dev/null +++ b/web/vendor/types/fields.d.ts @@ -0,0 +1,110 @@ +import type { FieldAlias, FieldId, FieldName } from "./index.js"; + +export type FieldOptions = Readonly>; +export type FieldSampler = ( + x: number, + y: number, + time?: number, + options?: FieldOptions, + kit?: FieldKit, +) => number; + +export const TAU: number; +export const BAYER8_SIZE: 8; +export const BAYER8: readonly number[]; +export function clamp(value: number, min?: number, max?: number): number; +export function lerp(a: number, b: number, amount: number): number; +export function smoothstep(edge0: number, edge1: number, value: number): number; +export function smootherstep(edge0: number, edge1: number, value: number): number; +export function fract(value: number): number; +export function bayer8(x: number, y: number): number; +export const bayerThreshold: typeof bayer8; +export function orderedDither( + value: number, + x: number, + y: number, + levels?: number, +): number; +export function hashSeed(seed?: unknown): number; + +export class FieldKit { + constructor(seed?: unknown | { seed: unknown }); + seed: number; + readonly permutation: Uint8Array; + reseed(seed?: unknown): this; + setSeed(seed?: unknown): this; + clone(): FieldKit; + list(): readonly FieldId[]; + has(id: unknown): boolean; + get(id: FieldName): FieldSampler | undefined; + sample( + id: FieldName, + x: number, + y: number, + time?: number, + options?: FieldOptions, + ): number; + hash2(x: number, y: number, salt?: number): number; + signedNoise2(x: number, y: number): number; + noise2(x: number, y: number): number; + signedNoise3(x: number, y: number, z: number): number; + noise3(x: number, y: number, z: number): number; + value2(x: number, y: number): number; + fbmNoise(x: number, y: number, z?: number, options?: FieldOptions): number; + ridgedNoise(x: number, y: number, z?: number, options?: FieldOptions): number; + curl2( + x: number, + y: number, + z?: number, + epsilon?: number, + out?: Float32Array | number[], + ): Float32Array | number[]; + cellular2(x: number, y: number, time?: number, options?: FieldOptions): number; + voronoi2(x: number, y: number, time?: number, options?: FieldOptions): number; + fbm(x: number, y: number, time?: number, options?: FieldOptions): number; + ridged(x: number, y: number, time?: number, options?: FieldOptions): number; + domainWarp(x: number, y: number, time?: number, options?: FieldOptions): number; + curl(x: number, y: number, time?: number, options?: FieldOptions): number; + flow(x: number, y: number, time?: number, options?: FieldOptions): number; + worley(x: number, y: number, time?: number, options?: FieldOptions): number; + voronoi(x: number, y: number, time?: number, options?: FieldOptions): number; + plasma(x: number, y: number, time?: number, options?: FieldOptions): number; + interference(x: number, y: number, time?: number, options?: FieldOptions): number; + vortex(x: number, y: number, time?: number, options?: FieldOptions): number; + metaballs(x: number, y: number, time?: number, options?: FieldOptions): number; + caustics(x: number, y: number, time?: number, options?: FieldOptions): number; + strata(x: number, y: number, time?: number, options?: FieldOptions): number; + radar(x: number, y: number, time?: number, options?: FieldOptions): number; + constellation(x: number, y: number, time?: number, options?: FieldOptions): number; + liquid(x: number, y: number, time?: number, options?: FieldOptions): number; + electric(x: number, y: number, time?: number, options?: FieldOptions): number; + ripple(x: number, y: number, time?: number, options?: FieldOptions): number; + kaleidoscope(x: number, y: number, time?: number, options?: FieldOptions): number; +} + +export const fbm: FieldSampler; +export const ridged: FieldSampler; +export const domainWarp: FieldSampler; +export const curl: FieldSampler; +export const flow: FieldSampler; +export const worley: FieldSampler; +export const voronoi: FieldSampler; +export const plasma: FieldSampler; +export const interference: FieldSampler; +export const vortex: FieldSampler; +export const metaballs: FieldSampler; +export const caustics: FieldSampler; +export const strata: FieldSampler; +export const radar: FieldSampler; +export const constellation: FieldSampler; +export const liquid: FieldSampler; +export const electric: FieldSampler; +export const ripple: FieldSampler; +export const kaleidoscope: FieldSampler; +export const FIELDS: Readonly>; +export const FIELD_IDS: readonly FieldId[]; +export const FIELD_ALIASES: Readonly>; +export const DEFAULT_FIELD_KIT: FieldKit; +export function createFieldKit(seed?: unknown): FieldKit; + +export default FieldKit; diff --git a/web/vendor/types/glyphs.d.ts b/web/vendor/types/glyphs.d.ts new file mode 100644 index 0000000..97e1bbe --- /dev/null +++ b/web/vendor/types/glyphs.d.ts @@ -0,0 +1,46 @@ +import type { GlyphSampleContext, GlyphSampler } from "./index.js"; + +export type GlyphId = + | "idle" + | "listening" + | "thinking" + | "thinking-deep" + | "still-working" + | "loading" + | "progress" + | "generating" + | "searching" + | "tool-use" + | "speaking" + | "awaiting-input" + | "success" + | "warning" + | "error" + | "paused" + | "cancelled" + | "offline" + | "transfer" + | "handoff" + | "celebration" + | "custom"; + +export const GLYPHS: Readonly>; +export const GLYPH_IDS: readonly GlyphId[]; +export const GLYPH_ALIASES: Readonly>; +export function resolveGlyphId(id: unknown): GlyphId; +export function getGlyph(id: unknown): GlyphSampler; +export function listGlyphs(): readonly GlyphId[]; +export function gestaltArcSupport( + angle: number, + context?: GlyphSampleContext, + openness?: number, +): number; +export function sampleGlyph( + id: unknown, + x: number, + y: number, + time?: number, + context?: GlyphSampleContext, +): number; + +export default GLYPHS; diff --git a/web/vendor/types/index.d.ts b/web/vendor/types/index.d.ts new file mode 100644 index 0000000..ad1cb14 --- /dev/null +++ b/web/vendor/types/index.d.ts @@ -0,0 +1,636 @@ +export type BuiltInSpriteId = + | "ai.idle" + | "ai.ambient-idle" + | "ai.ambient-thinking" + | "ai.ambient-thinking-symmetric" + | "ai.ambient-speaking" + | "ai.listening" + | "ai.thinking" + | "ai.thinking-deep" + | "ai.still-working" + | "ai.loading" + | "ai.progress" + | "ai.generating" + | "ai.searching" + | "ai.tool-use" + | "ai.speaking" + | "ai.awaiting-input" + | "status.success" + | "status.warning" + | "status.error" + | "status.paused" + | "status.cancelled" + | "status.offline" + | "transfer.active" + | "workflow.handoff" + | "status.celebration"; + +export type SpriteAlias = + | "idle" + | "ready" + | "ambient" + | "ambient-idle" + | "field-idle" + | "ambient-thinking" + | "field-thinking" + | "ambient-thinking-symmetric" + | "field-thinking-symmetric" + | "symmetric-thinking" + | "ambient-speaking" + | "field-speaking" + | "listening" + | "thinking" + | "thinking-deep" + | "deep-thinking" + | "still-working" + | "working" + | "loading" + | "progress" + | "generating" + | "searching" + | "tool-use" + | "tool" + | "speaking" + | "awaiting-input" + | "prompt" + | "success" + | "warning" + | "error" + | "paused" + | "cancelled" + | "offline" + | "transfer" + | "upload" + | "download" + | "sync" + | "handoff" + | "celebration"; + +export type SpriteName = BuiltInSpriteId | SpriteAlias; + +export type FieldId = + | "fbm" + | "ridged" + | "domain-warp" + | "curl" + | "flow" + | "worley" + | "voronoi" + | "plasma" + | "interference" + | "vortex" + | "metaballs" + | "caustics" + | "strata" + | "radar" + | "constellation" + | "liquid" + | "electric" + | "ripple" + | "kaleidoscope"; + +export type FieldAlias = + | "noise" + | "turbulence" + | "ridge" + | "domainWarp" + | "domain_warp" + | "warp" + | "curl-flow" + | "curlFlow" + | "cellular" + | "cells" + | "cell" + | "water" + | "lightning" + | "waves" + | "mandala"; + +export type FieldName = FieldId | FieldAlias; + +export type PixelShape = + | "disc" + | "square" + | "diamond" + | "capsule" + | "line" + | "ring" + | "cross" + | "square-cross" + | "square-cross-ring"; +export type PixelShapeAlias = "circle" | "rounded-square" | "dot"; +export type PixelShapeName = PixelShape | PixelShapeAlias; + +export type PixelSwitch = + | "ordered-dither" + | "temporal-blue-noise" + | "threshold-hysteresis" + | "sdf-wavefront" + | "contour-trace" + | "curl-advect" + | "neighbor-propagation" + | "radial-cascade" + | "path-draw" + | "axis-flip" + | "seeded-dissolve" + | "field-morph"; +export type PixelSwitchAlias = "cluster-dissolve" | "neighbor-ignite"; +export type PixelSwitchName = PixelSwitch | PixelSwitchAlias; + +export type Transition = + | "field-morph" + | "seeded-dissolve" + | "radial-cascade" + | "angular-sweep" + | "scanline" + | "contour-trace" + | "path-draw" + | "axis-flip" + | "cluster-dissolve" + | "neighbor-ignite" + | "glitch-bands" + | "instant"; +export type TransitionAlias = + | "dissolve" + | "radial" + | "bloom" + | "spiral" + | "wave" + | "shutter" + | "glitch" + | "sdf-wavefront" + | "neighbor-propagation"; +export type TransitionName = Transition | TransitionAlias; + +export type Quality = "low" | "balanced" | "high" | "auto"; +export type EffectiveQuality = Exclude; +export type ReducedMotion = boolean | "system"; +export type PreviewMode = "fit" | "actual"; +export type OrbBoundary = "defined" | "gestalt"; +export type OrbBackgroundMode = "none" | "solid" | "pixelated"; +export type CSSColor = string; + +export interface Palette { + /** Optional authored palette identifier preserved by exported configs. */ + name?: string; + background?: CSSColor; + /** Authored inactive color alias; effective palettes also expose `off`. */ + shadow?: CSSColor; + off?: CSSColor; + ink?: CSSColor; + accent?: CSSColor; + /** Use `transparent` to disable glow. */ + glow?: CSSColor; + /** Optional overrides selected by a recipe's authored palette name. */ + variants?: Readonly>; +} + +export interface FieldLayer { + field: FieldName; + weight?: number; + scale?: number; + blend?: string; + phase?: number; + speed?: number; + contrast?: number; + [key: string]: unknown; +} + +export interface TransitionRecipe { + name?: TransitionName; + type?: TransitionName; + enter?: TransitionName; + exit?: TransitionName; + duration?: number; + durationMs?: number; + exitDurationMs?: number; + preservePhase?: boolean; + preserveBuffer?: boolean; + interruptible?: boolean; + [key: string]: unknown; +} + +export interface PixelSwitchRecipe { + mode: PixelSwitchName; + dither?: string; + hysteresis?: number; + temporalRate?: number; + neighborhood?: number; + direction?: string | null; + [key: string]: unknown; +} + +export interface SpriteComposition { + mode?: "glyph" | "field-orb" | string; + /** Opts a circular recipe into the global Gestalt boundary preference. */ + gestaltBoundary?: boolean; + /** Controls how much open space remains between implied boundary arcs. */ + gestaltOpenness?: number; + [key: string]: unknown; +} + +export interface SpriteRecipe { + schemaVersion?: number; + id: string; + glyph: string; + /** A recipe can use one primary field, a field mix, or both. */ + field?: FieldName; + fieldMix?: readonly FieldLayer[]; + palette?: Readonly; + semantic?: Readonly>; + composition?: Readonly; + pixel?: Readonly<{ + shape?: PixelShapeName; + scale?: number; + gap?: number; + [key: string]: unknown; + }>; + pixelShape?: PixelShapeName; + pixelSwitch?: PixelSwitchName | Readonly; + transition?: TransitionName | Readonly; + interactions?: Readonly> | false; + timeline?: Readonly>; + reducedMotion?: Readonly>; + labels?: Readonly>; + threshold?: number; + density?: number; + speed?: number; + [key: string]: unknown; +} + +export interface JoanEngineOptions { + sprite?: SpriteName | SpriteRecipe; + seed?: string | number; + gridSize?: number; + speed?: number; + density?: number; + contrast?: number; + fps?: number; + pixelShape?: PixelShapeName | null; + pixelSwitch?: PixelSwitchName | PixelSwitchRecipe | null; + transition?: TransitionName | TransitionRecipe | null; + field?: FieldName | null; + fieldMode?: "recipe" | "override"; + palette?: Palette | null; + /** Compatibility alias for the global palette override. */ + paletteOverride?: Palette | null; + nonErrorPalette?: Palette | null; + previewMode?: PreviewMode; + /** Use an implied, discontinuous edge for supported circular presence states. */ + orbBoundary?: OrbBoundary; + /** Optional color used by the background layer inside supported presence orbs. */ + orbBackgroundColor?: CSSColor | null; + /** Selects no disk, a smooth disk, or a grid-aligned pixel disk. */ + orbBackgroundMode?: OrbBackgroundMode; + background?: boolean; + offPixels?: boolean; + interactive?: boolean; + autoplay?: boolean; + autoResize?: boolean; + dprMax?: number; + reducedMotion?: ReducedMotion; + quality?: Quality; + ariaLive?: HTMLElement | null; + direction?: string; + targetGlyph?: string | null; + progress?: number; +} + +export interface CreateProceduralGlyphOptions extends JoanEngineOptions { + canvas: HTMLCanvasElement; +} + +export interface SetSpriteOptions { + transition?: TransitionName | TransitionRecipe; + duration?: number; + preservePhase?: boolean; + preserveBuffer?: boolean; + immediate?: boolean; + forceTransition?: boolean; + useRecipeFields?: boolean; +} + +export interface TransitionToOptions extends SetSpriteOptions { + signal?: AbortSignal; +} + +export interface ActivateOptions { + x?: number; + y?: number; + energy?: number; + life?: number; + source?: string; + key?: string | null; +} + +export interface ResumeOptions extends SetSpriteOptions { + sprite?: SpriteName | SpriteRecipe; + source?: string; +} + +export interface SignalPayload { + value?: unknown; + energy?: number; + life?: number; + x?: number; + y?: number; + direction?: string; + source?: string; + [key: string]: unknown; +} + +export interface EngineStats { + fps: number; + activePixels: number; + frameMs: number; + resolution: number; + simulationSteps: number; + sampledPixels: number; + quality: EffectiveQuality; +} + +export interface EngineInspection { + sprite: string | null; + recipeSource: string | null; + previousSprite: string | null; + fieldMode: "recipe" | "override"; + field: string | null; + running: boolean; + visible: boolean; + destroyed: boolean; + reducedMotion: boolean; + transition: { + active: boolean; + name: Transition; + progress: number; + elapsed: number; + duration: number; + pending: string | null; + }; + signals: Array<{ type: string; value: number; age: number; life: number }>; + semanticSignals: Record; + palette: Palette; + orbBoundary: OrbBoundary; + orbBackgroundColor: CSSColor | null; + orbBackgroundMode: OrbBackgroundMode; + quality: { requested: Quality; effective: EffectiveQuality }; + stats: EngineStats; +} + +export interface TransitionDetail { + from: string | null; + to: string | null; + duration: number; + elapsed: number; + transition: Transition; + status: "completed" | "interrupted"; + reason: string; + serial: number; +} + +export interface JoanGlyphEventDetailMap { + activate: { + sprite: string; + x: number; + y: number; + source: string; + key: string | null; + }; + configchange: Partial & Record; + destroy: Record; + pause: { sprite: string }; + play: { sprite: string }; + qualitychange: { + from: EffectiveQuality; + to: EffectiveQuality; + frameMs: number; + budgetMs: number; + }; + resume: { + from: string; + to: string; + restoredBuffer: boolean; + source: string; + }; + signal: { type: string; payload: SignalPayload }; + spritechange: { from?: string; to: string; label: string }; + stats: EngineStats; + timelinecomplete: { from: string; to: string }; + transitioncomplete: TransitionDetail; + transitionqueued: { from?: string; to?: string }; +} + +export type JoanGlyphEventName = keyof JoanGlyphEventDetailMap; +export type JoanGlyphEvent = CustomEvent< + JoanGlyphEventDetailMap[K] +>; + +export interface ExportedJoanConfig { + package: "@joan/procedural-glyph-engine"; + version: string; + sprite: string; + seed: string; + gridSize: number; + speed: number; + density: number; + contrast: number; + fps: number; + field?: FieldName; + fieldMode: "recipe" | "override"; + pixelShape: PixelShape | null; + pixelSwitch: PixelSwitch | PixelSwitchRecipe | null; + transition: Transition | TransitionRecipe | null; + palette: Palette & + Required>; + paletteOverride: Palette | null; + nonErrorPalette: Palette | null; + orbBoundary: OrbBoundary; + orbBackgroundColor: CSSColor | null; + orbBackgroundMode: OrbBackgroundMode; + progress: number; + background: boolean; + offPixels: boolean; + interactive: boolean; + autoplay: boolean; + autoResize: boolean; + dprMax: number; + quality: Quality; + reducedMotion: ReducedMotion; + direction: string | null; + targetGlyph: string | null; +} + +export interface SvgExportOptions { + size?: number; + background?: boolean; + offPixels?: boolean; +} + +export interface AnimatedSvgExportOptions extends SvgExportOptions { + duration?: number; + fps?: number; + maxGridSize?: number; +} + +export interface GlyphSampleContext extends Record { + progress?: number; + audioLevel?: number; + energy?: number; + elapsed?: number; + resolution?: number; + reducedMotion?: boolean; + orbBoundary?: OrbBoundary; + gestaltOpenness?: number; +} + +export type GlyphSampler = ( + x: number, + y: number, + time: number, + context: GlyphSampleContext, +) => number; +export type GlyphBitmap = + | ArrayLike + | readonly (readonly number[])[]; + +export interface RegisterGlyphDimensions { + width?: number; + height?: number; +} + +export interface LoadGlyphFileOptions { + id?: string; + label?: string; + baseSprite?: SpriteName; + resolution?: number; + transition?: TransitionName | TransitionRecipe; +} + +export const TRANSITIONS: readonly Transition[]; +export const PIXEL_SWITCHES: readonly PixelSwitch[]; +export const PIXEL_SHAPES: readonly PixelShape[]; +export const ORB_BACKGROUND_MODES: readonly OrbBackgroundMode[]; +export const SPRITES: Readonly>>; + +export function getSprite(id: SpriteName): Readonly | undefined; +export function listSprites(): readonly Readonly[]; +export function transitionDelay( + transition: TransitionName, + x: number, + y: number, + random?: number, + origin?: { x: number; y: number }, +): number; + +export class JoanGlyphEngine extends EventTarget { + static readonly sprites: typeof SPRITES; + static listSprites(): readonly Readonly[]; + + constructor(canvas: HTMLCanvasElement, options?: JoanEngineOptions); + + readonly canvas: HTMLCanvasElement; + readonly context: CanvasRenderingContext2D; + options: JoanEngineOptions; + currentRecipe: SpriteRecipe; + recipeSource: SpriteRecipe; + previousRecipe: SpriteRecipe; + fieldOverride: FieldName | null; + gridSize: number; + seed: string; + running: boolean; + destroyed: boolean; + visible: boolean; + reducedMotion: boolean; + progress: number; + audioLevel: number; + clock: number; + stats: EngineStats; + readonly values: Float32Array; + readonly gates: Uint8Array; + readonly luminance: Float32Array; + + on( + type: K, + listener: (event: JoanGlyphEvent) => void, + options?: boolean | AddEventListenerOptions, + ): () => void; + addEventListener( + type: K, + listener: (this: JoanGlyphEngine, event: JoanGlyphEvent) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + + activate(options?: ActivateOptions): this; + resume(options?: ResumeOptions): this; + setSprite(sprite: SpriteName | SpriteRecipe, options?: SetSpriteOptions): this; + transitionTo( + sprite: SpriteName | SpriteRecipe, + options?: TransitionToOptions, + ): Promise; + whenTransitionComplete(options?: { signal?: AbortSignal }): Promise; + setField(field: FieldName): this; + useRecipeFields(): this; + setPixelSwitch(pixelSwitch: PixelSwitchName | PixelSwitchRecipe): this; + setPixelShape(pixelShape: PixelShapeName): this; + setResolution(size: number): this; + setSeed(seed: string | number): this; + setProgress(value: number): this; + setAudioLevel(value: number): this; + setNonErrorPalette( + palette: Palette | null, + options?: { render?: boolean }, + ): this; + configure( + options?: Partial, + configureOptions?: { strict?: boolean }, + ): this; + setOptions(options?: Partial): this; + signal(type: string, payload?: SignalPayload): this; + registerGlyph( + id: string, + source: GlyphSampler | GlyphBitmap, + dimensions?: RegisterGlyphDimensions, + ): this; + loadGlyphFile(file: Blob, options?: LoadGlyphFileOptions): Promise; + play(): this; + pause(): this; + toggle(): this; + renderFrame(time?: number, dt?: number, force?: boolean): EngineStats; + renderOnce(time?: number): EngineStats; + inspect(): EngineInspection; + exportConfig(): ExportedJoanConfig; + toSVG(options?: SvgExportOptions): string; + toAnimatedSVG(options?: AnimatedSvgExportOptions): Promise; + downloadPNG(filename?: string): Promise; + downloadSVG(filename?: string, options?: SvgExportOptions): Promise; + downloadAnimatedSVG( + filename?: string, + options?: AnimatedSvgExportOptions, + ): Promise; + toDataURL(type?: string, quality?: number): string; + toBlob(type?: string, quality?: number): Promise; + destroy(): void; +} + +export function createProceduralGlyph( + options: CreateProceduralGlyphOptions, +): JoanGlyphEngine; +export function createProceduralGlyph( + target: string | HTMLCanvasElement, + options?: JoanEngineOptions, +): JoanGlyphEngine; +export function createGlyph( + target: string | HTMLCanvasElement, + options?: JoanEngineOptions, +): JoanGlyphEngine; +export function mountProceduralGlyph( + target: string | HTMLCanvasElement, + options?: JoanEngineOptions, +): JoanGlyphEngine; + +export default JoanGlyphEngine; diff --git a/web/vendor/types/sprites.d.ts b/web/vendor/types/sprites.d.ts new file mode 100644 index 0000000..bd8e3c3 --- /dev/null +++ b/web/vendor/types/sprites.d.ts @@ -0,0 +1,23 @@ +import type { + BuiltInSpriteId, + FieldId, + Palette, + SpriteAlias, + SpriteName, + SpriteRecipe, +} from "./index.js"; + +export const FIELD_IDS: readonly FieldId[]; +export const PALETTES: Readonly>>; +export const SPRITES: Readonly< + Record> +>; +export const SPRITE_IDS: readonly BuiltInSpriteId[]; +export const SPRITE_ALIASES: Readonly>; + +export function resolveSpriteId(id: unknown): BuiltInSpriteId | undefined; +export function getSprite(id: SpriteName): Readonly | undefined; +export function listSprites(): readonly Readonly[]; +export function hasSprite(id: unknown): boolean; + +export default SPRITES; diff --git a/web/vendor/types/state-director.d.ts b/web/vendor/types/state-director.d.ts new file mode 100644 index 0000000..213f49b --- /dev/null +++ b/web/vendor/types/state-director.d.ts @@ -0,0 +1,81 @@ +import type { + JoanGlyphEngine, + SetSpriteOptions, + SpriteName, + SpriteRecipe, + TransitionName, +} from "./index.js"; + +export type DirectorSprite = SpriteName | SpriteRecipe; + +export interface StateDirectorOptions { + deepThinkingAfterMs?: number; + stillWorkingAfterMs?: number; + transition?: TransitionName; +} + +export interface DirectorSetOptions extends SetSpriteOptions { + reason?: string; + sprite?: DirectorSprite; +} + +export interface StateChangeDetail { + state: string; + from: string; + to: string; + reason: string; +} + +export interface DirectorRunContext { + signal?: AbortSignal; + director: StateDirector; + engine: JoanGlyphEngine; +} + +export interface DirectorRunOptions extends StateDirectorOptions { + signal?: AbortSignal; + thinking?: StateDirectorOptions & DirectorSetOptions; + complete?: DirectorSetOptions; + fail?: DirectorSetOptions; + cancel?: DirectorSetOptions; + successSprite?: DirectorSprite; + errorSprite?: DirectorSprite; + cancelledSprite?: DirectorSprite; +} + +export class StateDirector extends EventTarget { + constructor(engine: JoanGlyphEngine, options?: StateDirectorOptions); + readonly engine: JoanGlyphEngine; + readonly options: Required; + readonly timers: Set>; + state: string; + destroyed: boolean; + + addEventListener( + type: "statechange", + listener: (this: StateDirector, event: CustomEvent) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + + clearTimers(): void; + set(sprite: DirectorSprite, options?: DirectorSetOptions): this; + beginThinking(options?: StateDirectorOptions & DirectorSetOptions): this; + complete(options?: DirectorSetOptions): this; + fail(options?: DirectorSetOptions): this; + cancel(options?: DirectorSetOptions): this; + reset(options?: DirectorSetOptions): this; + run( + work: + | PromiseLike + | ((context: DirectorRunContext) => T | PromiseLike), + options?: DirectorRunOptions, + ): Promise; + destroy(): void; +} + +export default StateDirector; diff --git a/web/vendor/types/state-sequence.d.ts b/web/vendor/types/state-sequence.d.ts new file mode 100644 index 0000000..e5fe5b5 --- /dev/null +++ b/web/vendor/types/state-sequence.d.ts @@ -0,0 +1,214 @@ +import type { + SetSpriteOptions, + SpriteName, + SpriteRecipe, + TransitionDetail, + TransitionName, + TransitionToOptions, +} from "./index.js"; + +export const STATE_SEQUENCE_SCHEMA_VERSION: 1; + +export type StateSequenceSprite = SpriteName | SpriteRecipe; +export type StateSequenceTransition = + | false + | TransitionName + | (SetSpriteOptions & { name?: TransitionName }); + +export interface StateSequenceStep { + sprite: StateSequenceSprite; + /** Time to keep the entered state before advancing, in milliseconds. */ + holdMs?: number; + /** Compatibility alias for holdMs; normalized definitions use holdMs. */ + durationMs?: number; + transition?: StateSequenceTransition; + options?: SetSpriteOptions; + label?: string; + metadata?: Readonly>; +} + +export interface DefinedStateSequenceStep + extends Omit { + readonly holdMs: number; +} + +export interface StateSequenceDefinition { + schemaVersion?: number; + name: string; + steps: readonly (StateSequenceStep | SpriteName)[]; + transition?: StateSequenceTransition; + transitionFirst?: boolean; + metadata?: Readonly>; +} + +export interface DefinedStateSequence { + readonly schemaVersion: 1; + readonly name: string; + readonly steps: readonly Readonly[]; + readonly transition?: StateSequenceTransition; + readonly transitionFirst: boolean; + readonly metadata?: Readonly>; +} + +export interface StateSequenceTarget { + currentRecipe?: { id?: string } | null; + setSprite(sprite: StateSequenceSprite, options?: SetSpriteOptions): unknown; + transitionTo?( + sprite: StateSequenceSprite, + options?: TransitionToOptions, + ): PromiseLike | TransitionDetail | unknown; +} + +export interface StateSequenceClock { + now(): number; + setTimeout(callback: () => void, delayMs: number): unknown; + clearTimeout(timer: unknown): void; +} + +export interface StateSequencePlayerOptions { + transition?: StateSequenceTransition; + transitionFirst?: boolean; + preservePhase?: boolean; + clock?: StateSequenceClock; + sequences?: + | readonly (DefinedStateSequence | StateSequenceDefinition)[] + | Readonly< + Record< + string, + | readonly (StateSequenceStep | SpriteName)[] + | StateSequenceDefinition + > + >; +} + +export interface StateSequencePlayOptions { + name?: string; + signal?: AbortSignal; + transition?: StateSequenceTransition; + transitionFirst?: boolean; + preservePhase?: boolean; +} + +export interface StateSequenceResult { + readonly runId: number; + readonly name: string; + readonly sequence: DefinedStateSequence; + readonly status: "completed" | "stopped"; + readonly reason: unknown; + readonly completedSteps: number; +} + +export interface StateSequenceEventDetail { + readonly runId: number; + readonly name: string; + readonly sequence: DefinedStateSequence; + readonly index: number; + readonly step?: Readonly; + readonly sprite?: string; + readonly from?: string | null; + readonly to?: string; + readonly state?: string; + readonly reason?: unknown; + readonly error?: unknown; + readonly totalSteps?: number; + readonly completedSteps?: number; +} + +export type StateSequenceEventName = + | "sequencestart" + | "stepstart" + | "statechange" + | "stepenter" + | "stepcomplete" + | "sequencepause" + | "sequenceresume" + | "sequencestop" + | "sequencecancel" + | "sequencecomplete" + | "sequenceerror"; + +export function defineStateSequence( + name: string, + steps: readonly (StateSequenceStep | SpriteName)[], + options?: Omit, +): DefinedStateSequence; +export function defineStateSequence( + definition: StateSequenceDefinition, +): DefinedStateSequence; + +export class StateSequencePlayer extends EventTarget { + constructor(target: StateSequenceTarget, options?: StateSequencePlayerOptions); + readonly target: StateSequenceTarget; + readonly sequences: Map; + readonly currentSequence: DefinedStateSequence | null; + readonly currentStepIndex: number; + readonly isPlaying: boolean; + readonly isPaused: boolean; + status: "idle" | "running" | "paused" | "destroyed"; + state: string | null; + destroyed: boolean; + finished: Promise; + + addEventListener( + type: StateSequenceEventName, + listener: ( + this: StateSequencePlayer, + event: CustomEvent, + ) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + + register( + name: string, + steps: readonly (StateSequenceStep | SpriteName)[], + options?: Omit, + ): DefinedStateSequence; + register( + definition: DefinedStateSequence | StateSequenceDefinition, + ): DefinedStateSequence; + unregister(name: string): boolean; + getSequence(name: string): DefinedStateSequence | null; + listSequences(): readonly DefinedStateSequence[]; + play( + sequence: + | string + | DefinedStateSequence + | StateSequenceDefinition + | readonly (StateSequenceStep | SpriteName)[], + options?: StateSequencePlayOptions, + ): Promise; + run( + sequence: + | string + | DefinedStateSequence + | StateSequenceDefinition + | readonly (StateSequenceStep | SpriteName)[], + options?: StateSequencePlayOptions, + ): Promise; + /** + * Pauses the hold clock and step progression. It does not rewind or suspend + * a visual transition already running inside the target engine. + */ + pause(): this; + resume(): this; + stop(reason?: string): this; + destroy(): void; +} + +export interface PlayStateSequenceOptions extends StateSequencePlayOptions { + clock?: StateSequenceClock; + metadata?: Readonly>; +} + +export function playStateSequence( + target: StateSequenceTarget, + steps: readonly (StateSequenceStep | SpriteName)[], + options?: PlayStateSequenceOptions, +): StateSequencePlayer; + +export default StateSequencePlayer; diff --git a/web/vendor/types/web-component-register.d.ts b/web/vendor/types/web-component-register.d.ts new file mode 100644 index 0000000..89ee458 --- /dev/null +++ b/web/vendor/types/web-component-register.d.ts @@ -0,0 +1,6 @@ +export { + JOAN_GLYPH_TAG_NAME, + JoanGlyphElement, + defineJoanGlyph, +} from "./web-component.js"; +export { default } from "./web-component.js"; diff --git a/web/vendor/types/web-component.d.ts b/web/vendor/types/web-component.d.ts new file mode 100644 index 0000000..bb8de90 --- /dev/null +++ b/web/vendor/types/web-component.d.ts @@ -0,0 +1,93 @@ +import type { + ActivateOptions, + EngineStats, + EngineInspection, + ExportedJoanConfig, + FieldName, + JoanEngineOptions, + JoanGlyphEngine, + Palette, + PixelSwitchRecipe, + PixelShapeName, + PixelSwitchName, + ResumeOptions, + SetSpriteOptions, + SignalPayload, + SpriteName, + SpriteRecipe, + TransitionDetail, + TransitionToOptions, + JoanGlyphEventName, + JoanGlyphEvent, +} from "./index.js"; + +export const JOAN_GLYPH_TAG_NAME: "joan-glyph"; + +export class JoanGlyphElement extends HTMLElement { + static readonly observedAttributes: readonly string[]; + readonly canvas: HTMLCanvasElement; + readonly engine: JoanGlyphEngine | null | undefined; + + connectedCallback(): void; + disconnectedCallback(): void; + attributeChangedCallback( + name: string, + oldValue: string | null, + newValue: string | null, + ): void; + readOptions(): JoanEngineOptions; + readNonErrorPalette(): Palette | null; + setSprite( + sprite: SpriteName | SpriteRecipe, + options?: SetSpriteOptions, + ): JoanGlyphEngine | undefined; + setProgress(value: number): JoanGlyphEngine | undefined; + setNonErrorPalette(palette: Palette | null): JoanGlyphEngine | undefined; + setSeed(seed: string | number): JoanGlyphEngine | undefined; + setResolution(size: number): JoanGlyphEngine | undefined; + setField(field: FieldName): JoanGlyphEngine | undefined; + useRecipeFields(): JoanGlyphEngine | undefined; + setPixelShape(shape: PixelShapeName): JoanGlyphEngine | undefined; + setPixelSwitch( + pixelSwitch: PixelSwitchName | PixelSwitchRecipe, + ): JoanGlyphEngine | undefined; + setOptions(options: Partial): JoanGlyphEngine | undefined; + configure( + options?: Partial, + configureOptions?: { strict?: boolean }, + ): JoanGlyphEngine | undefined; + setAudioLevel(value: number): JoanGlyphEngine | undefined; + activate(options?: ActivateOptions): JoanGlyphEngine | undefined; + resume(options?: ResumeOptions): JoanGlyphEngine | undefined; + play(): JoanGlyphEngine | undefined; + pause(): JoanGlyphEngine | undefined; + toggle(): JoanGlyphEngine | undefined; + renderOnce(time?: number): EngineStats | undefined; + transitionTo( + sprite: SpriteName | SpriteRecipe, + options?: TransitionToOptions, + ): Promise | undefined; + whenTransitionComplete(options?: { + signal?: AbortSignal; + }): Promise | undefined; + inspect(): EngineInspection | undefined; + on( + type: K, + listener: (event: JoanGlyphEvent) => void, + options?: boolean | AddEventListenerOptions, + ): (() => void) | undefined; + exportConfig(): ExportedJoanConfig | undefined; + signal(type: string, payload?: SignalPayload): JoanGlyphEngine | undefined; +} + +export function defineJoanGlyph( + tagName?: string, +): typeof JoanGlyphElement; + +declare global { + interface HTMLElementTagNameMap { + "joan-glyph": JoanGlyphElement; + } +} + +export default JoanGlyphElement; diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..85edc57 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,97 @@ +/// +import type { ProxyOptions } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite' +import { readFileSync, existsSync } from 'fs' +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' + +// In Docker, VERSION is copied into the build WORKDIR (/build/web/VERSION). +// In local dev, the cwd is web/ and VERSION is two dirs up (../../VERSION +// from vite.config.ts location in web/). Try both. +let version: string +if (existsSync('../VERSION')) { + version = readFileSync('../VERSION', 'utf-8').trim() +} else { + version = readFileSync('VERSION', 'utf-8').trim() +} + +// Injects OIKOS_API_TOKEN into proxied /api requests in dev — the API no +// longer has a dev-open bypass (plans/2026-07-12-wails-desktop-app.md 0.4), +// so `OIKOS_API_TOKEN=dev-token npm run dev` needs this to reach it. +function authProxy(target: string, rewrite?: (path: string) => string): ProxyOptions { + return { + target, + ...(rewrite ? { rewrite } : {}), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + const token = process.env.OIKOS_API_TOKEN + if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`) + }) + } + } +} + +// Where `npm run dev` proxies to. The SPA is hardwired to same-origin in dev +// (see the __OIKOS_DEV_TOKEN__ define below), so these targets — not +// localStorage — decide which backend a dev session actually talks to. They +// default to the local prod stack, which is what you want day to day; override +// them to point a dev SPA at a scratch API without touching this file: +// +// OIKOS_API_PROXY=http://127.0.0.1:8199 npm run dev +const apiTarget = process.env.OIKOS_API_PROXY ?? 'http://localhost:8090' +const nomosTarget = process.env.OIKOS_NOMOS_PROXY ?? 'http://localhost:8092' + +export default defineConfig({ + plugins: [tailwindcss(), svelte()], + base: '/', + define: { + __OIKOS_VERSION__: JSON.stringify(`v${version}`), + // Lets the dev server auto-configure the SPA with the same token it + // already injects into proxied requests (see authProxy above), so `npm + // run dev` skips the "Connect to Oikos" prompt instead of re-asking for + // a token every time localStorage gets cleared. Empty string (never a + // real prod secret — see main.ts, only consulted in import.meta.env.DEV) + // when OIKOS_API_TOKEN isn't set, so the prompt still shows if unconfigured. + __OIKOS_DEV_TOKEN__: JSON.stringify(process.env.OIKOS_API_TOKEN ?? '') + }, + resolve: { + alias: { + $lib: '/src/lib', + // svelte-splitpanes imports SvelteKit's browser-detection module; this + // isn't a SvelteKit app, so point it at a plain shim (see the file). + // Needs a real filesystem path (not the /src/... shorthand $lib uses) + // so esbuild's dependency pre-bundler can resolve it too. + '$app/environment': fileURLToPath( + new URL('./src/lib/shims/app-environment.ts', import.meta.url) + ) + } + }, + build: { + outDir: 'dist', + emptyOutDir: true + }, + optimizeDeps: { + // svelte-splitpanes imports SvelteKit's $app/environment (aliased above + // to a shim), but esbuild's dependency pre-bundler resolves that + // differently and fails before the dev server even starts — skip + // pre-bundling it so it goes through Vite's normal (alias-aware) + // transform pipeline instead. + exclude: ['svelte-splitpanes'] + }, + server: { + proxy: { + '/api': authProxy(apiTarget), + // Production Caddy strips /agent before forwarding to nomos + // (compose/caddy/Caddyfile.oikos handle_path /agent/*); match that + // here so dev and prod agree on nomos's actual route paths. + '/agent': authProxy(nomosTarget, (path) => path.replace(/^\/agent/, '')) + } + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['src/test-setup.ts'], + include: ['src/**/*.{test,spec}.{ts,js}'] + } +})