From ed8a3145b3d918159bbee76441a623708476b407 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 15 Aug 2026 22:20:54 +0200 Subject: [PATCH] oikos-web: extract the client stack from dtoro/oikos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/ci.yml | 36 + .gitea/workflows/desktop.yml | 70 + .gitignore | 16 + Makefile | 40 + README.md | 50 + VERSION | 1 + compose/Caddyfile | 27 + compose/Dockerfile | 20 + desktop/Info.plist.template | 30 + desktop/Taskfile.yml | 14 + desktop/assets_embed.go | 12 + desktop/assets_stub.go | 12 + desktop/entitlements.plist | 26 + desktop/icon.icns | Bin 0 -> 152668 bytes desktop/icon.png | Bin 0 -> 1573 bytes desktop/main.go | 790 ++ desktop/tray-icon.svg | 5 + desktop/wails.json | 9 + docker-compose.yml | 20 + go.mod | 20 + go.sum | 41 + scripts/deploy.sh | 223 + scripts/install-webhook.sh | 86 + scripts/oikos-web-deploy-webhook.plist | 38 + web/.prettierignore | 4 + web/.prettierrc.json | 10 + web/components.json | 17 + web/eslint.config.js | 39 + web/index.html | 31 + web/package-lock.json | 6425 +++++++++++++++++ web/package.json | 59 + web/public/android-chrome-192.png | Bin 0 -> 84522 bytes web/public/android-chrome-512.png | Bin 0 -> 84522 bytes web/public/apple-touch-icon.png | Bin 0 -> 84522 bytes web/public/favicon.png | Bin 0 -> 84522 bytes web/public/favicon.svg | 4 + web/public/fonts/JetBrainsMono-Bold.woff2 | Bin 0 -> 21908 bytes web/public/fonts/JetBrainsMono-Regular.woff2 | Bin 0 -> 21168 bytes web/public/fonts/VT323-Regular.woff2 | Bin 0 -> 17936 bytes web/public/mascot/LICENSE-eggs.txt | 20 + web/public/mascot/LICENSE.txt | 5 + web/public/mascot/blink.png | Bin 0 -> 343 bytes web/public/mascot/egg-crack.png | Bin 0 -> 167 bytes web/public/mascot/egg-idle.png | Bin 0 -> 132 bytes web/public/mascot/egg-shell.png | Bin 0 -> 112 bytes web/public/mascot/hurt.png | Bin 0 -> 416 bytes web/public/mascot/idle.png | Bin 0 -> 368 bytes web/public/mascot/jump.png | Bin 0 -> 335 bytes web/public/mascot/peck.png | Bin 0 -> 382 bytes web/public/mascot/peep.png | Bin 0 -> 295 bytes web/public/mascot/react-displeased.png | Bin 0 -> 330 bytes web/public/mascot/react-joy.png | Bin 0 -> 379 bytes web/public/mascot/react-sigh.png | Bin 0 -> 365 bytes web/public/mascot/react-surprise.png | Bin 0 -> 361 bytes web/public/mascot/react-yell.png | Bin 0 -> 346 bytes web/public/mascot/sleep.png | Bin 0 -> 331 bytes web/public/mascot/walk.png | Bin 0 -> 375 bytes web/public/mascot/walk2.png | Bin 0 -> 390 bytes web/src/App.svelte | 66 + web/src/app.css | 626 ++ web/src/lib/api.ts | 1167 +++ web/src/lib/app-store/apps/Notes.svelte | 57 + web/src/lib/app-store/catalog.ts | 81 + web/src/lib/apps.test.ts | 135 + web/src/lib/apps.ts | 292 + web/src/lib/components/ChatThread.svelte | 954 +++ .../lib/components/ConfigBackground.svelte | 170 + web/src/lib/components/DetailSection.svelte | 43 + web/src/lib/components/EmptyState.svelte | 20 + .../lib/components/EntityDetailContent.svelte | 1188 +++ web/src/lib/components/EntityTable.svelte | 258 + web/src/lib/components/FilterTabs.svelte | 42 + web/src/lib/components/FleetMap.svelte | 1311 ++++ web/src/lib/components/GlyphIndicator.svelte | 89 + .../lib/components/OperatorQuestion.svelte | 89 + web/src/lib/components/RasterImage.svelte | 206 + .../lib/components/SessionChatWindow.svelte | 119 + web/src/lib/components/SessionGraph.svelte | 650 ++ web/src/lib/components/Spinner.svelte | 34 + web/src/lib/components/StatusBadge.svelte | 50 + .../lib/components/TaskContextPanel.svelte | 65 + web/src/lib/components/ThinkingBlock.svelte | 106 + web/src/lib/components/ToolLine.svelte | 258 + web/src/lib/components/TurnTrace.svelte | 325 + .../components/data-table/DataTable.svelte | 242 + .../components/data-table/SearchInput.svelte | 55 + .../components/data-table/SortHeader.svelte | 30 + .../lib/components/data-table/Toolbar.svelte | 33 + web/src/lib/components/data-table/columns.ts | 9 + .../data-table/pagination/PageButton.svelte | 22 + .../data-table/pagination/Pagination.svelte | 42 + .../data-table/pagination/RowsPerPage.svelte | 29 + .../renderers/ActivityAction.svelte | 23 + .../renderers/ActivityCancel.svelte | 22 + .../renderers/ApprovalActions.svelte | 28 + .../data-table/renderers/BadgeRenderer.svelte | 8 + .../data-table/renderers/DateRenderer.svelte | 14 + .../renderers/DurationRenderer.svelte | 15 + .../renderers/HealthDotRenderer.svelte | 32 + .../renderers/RelativeTimeRenderer.svelte | 7 + .../data-table/renderers/SignalActions.svelte | 31 + .../renderers/StatusBadgeRenderer.svelte | 56 + .../renderers/StatusDotRenderer.svelte | 13 + web/src/lib/components/data-table/types.ts | 36 + .../components/desktop-shell/Desktop.svelte | 152 + .../desktop-shell/DesktopIcon.svelte | 109 + .../desktop-shell/DockedLayer.svelte | 24 + .../components/desktop-shell/LazyApp.svelte | 37 + .../desktop-shell/NewTaskChat.svelte | 35 + .../desktop-shell/TaskLauncher.svelte | 63 + .../components/desktop-shell/Taskbar.svelte | 133 + .../desktop-shell/WindowLayer.svelte | 121 + .../components/knowledge/WikiCleanup.svelte | 626 ++ .../knowledge/WikiContextRail.svelte | 124 + .../components/knowledge/WikiNewDialog.svelte | 139 + .../components/knowledge/WikiOverview.svelte | 201 + .../components/knowledge/WikiQuickOpen.svelte | 119 + .../components/knowledge/WikiReader.svelte | 485 ++ .../lib/components/knowledge/WikiTree.svelte | 198 + web/src/lib/components/knowledge/kinds.ts | 50 + web/src/lib/components/knowledge/wikiText.ts | 197 + web/src/lib/components/ui/badge/badge.svelte | 50 + web/src/lib/components/ui/badge/index.ts | 2 + .../lib/components/ui/button/button.svelte | 89 + web/src/lib/components/ui/button/index.ts | 17 + .../lib/components/ui/card/card-action.svelte | 23 + .../components/ui/card/card-content.svelte | 20 + .../ui/card/card-description.svelte | 20 + .../lib/components/ui/card/card-footer.svelte | 23 + .../lib/components/ui/card/card-header.svelte | 23 + .../lib/components/ui/card/card-title.svelte | 20 + web/src/lib/components/ui/card/card.svelte | 25 + web/src/lib/components/ui/card/index.ts | 25 + .../components/ui/checkbox/checkbox.svelte | 39 + web/src/lib/components/ui/checkbox/index.ts | 1 + .../ui/collapsible/collapsible-content.svelte | 7 + .../ui/collapsible/collapsible-trigger.svelte | 7 + .../ui/collapsible/collapsible.svelte | 11 + .../lib/components/ui/collapsible/index.ts | 13 + .../context-menu-checkbox-item.svelte | 41 + .../context-menu/context-menu-content.svelte | 28 + .../context-menu-group-heading.svelte | 21 + .../ui/context-menu/context-menu-group.svelte | 7 + .../ui/context-menu/context-menu-item.svelte | 27 + .../ui/context-menu/context-menu-label.svelte | 27 + .../context-menu/context-menu-portal.svelte | 7 + .../context-menu-radio-group.svelte | 16 + .../context-menu-radio-item.svelte | 35 + .../context-menu-separator.svelte | 17 + .../context-menu/context-menu-shortcut.svelte | 23 + .../context-menu-sub-content.svelte | 20 + .../context-menu-sub-trigger.svelte | 29 + .../ui/context-menu/context-menu-sub.svelte | 7 + .../context-menu/context-menu-trigger.svelte | 17 + .../ui/context-menu/context-menu.svelte | 7 + .../lib/components/ui/context-menu/index.ts | 52 + .../components/ui/dialog/dialog-close.svelte | 11 + .../ui/dialog/dialog-content.svelte | 48 + .../ui/dialog/dialog-description.svelte | 20 + .../components/ui/dialog/dialog-footer.svelte | 32 + .../components/ui/dialog/dialog-header.svelte | 20 + .../ui/dialog/dialog-overlay.svelte | 20 + .../components/ui/dialog/dialog-portal.svelte | 7 + .../components/ui/dialog/dialog-title.svelte | 17 + .../ui/dialog/dialog-trigger.svelte | 11 + .../lib/components/ui/dialog/dialog.svelte | 7 + web/src/lib/components/ui/dialog/index.ts | 34 + .../dropdown-menu-checkbox-group.svelte | 16 + .../dropdown-menu-checkbox-item.svelte | 44 + .../dropdown-menu-content.svelte | 31 + .../dropdown-menu-group-heading.svelte | 22 + .../dropdown-menu/dropdown-menu-group.svelte | 7 + .../dropdown-menu/dropdown-menu-item.svelte | 27 + .../dropdown-menu/dropdown-menu-label.svelte | 27 + .../dropdown-menu/dropdown-menu-portal.svelte | 7 + .../dropdown-menu-radio-group.svelte | 16 + .../dropdown-menu-radio-item.svelte | 34 + .../dropdown-menu-separator.svelte | 17 + .../dropdown-menu-shortcut.svelte | 23 + .../dropdown-menu-sub-content.svelte | 20 + .../dropdown-menu-sub-trigger.svelte | 29 + .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 + .../dropdown-menu-trigger.svelte | 7 + .../ui/dropdown-menu/dropdown-menu.svelte | 7 + .../lib/components/ui/dropdown-menu/index.ts | 54 + web/src/lib/components/ui/input/index.ts | 7 + web/src/lib/components/ui/input/input.svelte | 48 + web/src/lib/components/ui/label/index.ts | 7 + web/src/lib/components/ui/label/label.svelte | 16 + .../lib/components/ui/scroll-area/index.ts | 10 + .../scroll-area/scroll-area-scrollbar.svelte | 30 + .../ui/scroll-area/scroll-area.svelte | 43 + web/src/lib/components/ui/select/index.ts | 37 + .../ui/select/select-content.svelte | 45 + .../ui/select/select-group-heading.svelte | 21 + .../components/ui/select/select-group.svelte | 17 + .../components/ui/select/select-item.svelte | 40 + .../components/ui/select/select-label.svelte | 20 + .../components/ui/select/select-portal.svelte | 7 + .../select/select-scroll-down-button.svelte | 23 + .../ui/select/select-scroll-up-button.svelte | 23 + .../ui/select/select-separator.svelte | 18 + .../ui/select/select-trigger.svelte | 29 + .../lib/components/ui/select/select.svelte | 11 + web/src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 23 + web/src/lib/components/ui/sheet/index.ts | 34 + .../components/ui/sheet/sheet-close.svelte | 7 + .../components/ui/sheet/sheet-content.svelte | 55 + .../ui/sheet/sheet-description.svelte | 17 + .../components/ui/sheet/sheet-footer.svelte | 20 + .../components/ui/sheet/sheet-header.svelte | 20 + .../components/ui/sheet/sheet-overlay.svelte | 17 + .../components/ui/sheet/sheet-portal.svelte | 7 + .../components/ui/sheet/sheet-title.svelte | 17 + .../components/ui/sheet/sheet-trigger.svelte | 7 + web/src/lib/components/ui/sheet/sheet.svelte | 7 + .../lib/components/ui/sidebar/constants.ts | 6 + .../components/ui/sidebar/context.svelte.ts | 79 + web/src/lib/components/ui/sidebar/index.ts | 75 + .../ui/sidebar/sidebar-content.svelte | 24 + .../ui/sidebar/sidebar-footer.svelte | 21 + .../ui/sidebar/sidebar-group-action.svelte | 33 + .../ui/sidebar/sidebar-group-content.svelte | 21 + .../ui/sidebar/sidebar-group-label.svelte | 33 + .../ui/sidebar/sidebar-group.svelte | 21 + .../ui/sidebar/sidebar-header.svelte | 21 + .../ui/sidebar/sidebar-input.svelte | 21 + .../ui/sidebar/sidebar-inset.svelte | 23 + .../ui/sidebar/sidebar-menu-action.svelte | 37 + .../ui/sidebar/sidebar-menu-badge.svelte | 24 + .../ui/sidebar/sidebar-menu-button.svelte | 104 + .../ui/sidebar/sidebar-menu-item.svelte | 21 + .../ui/sidebar/sidebar-menu-skeleton.svelte | 36 + .../ui/sidebar/sidebar-menu-sub-button.svelte | 42 + .../ui/sidebar/sidebar-menu-sub-item.svelte | 21 + .../ui/sidebar/sidebar-menu-sub.svelte | 24 + .../components/ui/sidebar/sidebar-menu.svelte | 21 + .../ui/sidebar/sidebar-provider.svelte | 53 + .../components/ui/sidebar/sidebar-rail.svelte | 36 + .../ui/sidebar/sidebar-separator.svelte | 19 + .../ui/sidebar/sidebar-trigger.svelte | 36 + .../lib/components/ui/sidebar/sidebar.svelte | 105 + web/src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 17 + web/src/lib/components/ui/slider/index.ts | 7 + .../lib/components/ui/slider/slider.svelte | 50 + web/src/lib/components/ui/sonner/index.ts | 1 + .../lib/components/ui/sonner/sonner.svelte | 34 + web/src/lib/components/ui/switch/index.ts | 1 + .../lib/components/ui/switch/switch.svelte | 27 + web/src/lib/components/ui/table/index.ts | 28 + .../lib/components/ui/table/table-body.svelte | 20 + .../components/ui/table/table-caption.svelte | 20 + .../lib/components/ui/table/table-cell.svelte | 20 + .../components/ui/table/table-footer.svelte | 20 + .../lib/components/ui/table/table-head.svelte | 23 + .../components/ui/table/table-header.svelte | 20 + .../lib/components/ui/table/table-row.svelte | 23 + web/src/lib/components/ui/table/table.svelte | 22 + web/src/lib/components/ui/tabs/index.ts | 18 + .../components/ui/tabs/tabs-content.svelte | 17 + .../lib/components/ui/tabs/tabs-list.svelte | 40 + .../components/ui/tabs/tabs-trigger.svelte | 23 + web/src/lib/components/ui/tabs/tabs.svelte | 19 + web/src/lib/components/ui/textarea/index.ts | 7 + .../components/ui/textarea/textarea.svelte | 23 + web/src/lib/components/ui/tooltip/index.ts | 19 + .../ui/tooltip/tooltip-content.svelte | 52 + .../ui/tooltip/tooltip-portal.svelte | 7 + .../ui/tooltip/tooltip-provider.svelte | 7 + .../ui/tooltip/tooltip-trigger.svelte | 7 + .../lib/components/ui/tooltip/tooltip.svelte | 7 + web/src/lib/config.ts | 135 + web/src/lib/desktop-patterns.ts | 84 + web/src/lib/entityView.test.ts | 155 + web/src/lib/entityView.ts | 169 + web/src/lib/health.test.ts | 85 + web/src/lib/health.ts | 54 + web/src/lib/hooks/is-mobile.svelte.ts | 9 + web/src/lib/mascot/Mascot.svelte | 905 +++ web/src/lib/mascot/MascotLayer.svelte | 243 + web/src/lib/mascot/MascotMenu.svelte | 55 + web/src/lib/mascot/NameDialog.svelte | 98 + web/src/lib/mascot/actions.ts | 272 + web/src/lib/mascot/behavior.ts | 631 ++ web/src/lib/mascot/render.ts | 61 + web/src/lib/mascot/sprites.ts | 143 + web/src/lib/mascot/state.svelte.ts | 208 + web/src/lib/mascot/stimuli.ts | 224 + web/src/lib/mascot/types.ts | 192 + web/src/lib/oidc.ts | 327 + web/src/lib/shims/app-environment.ts | 4 + web/src/lib/stores/activity.test.ts | 247 + web/src/lib/stores/activity.ts | 580 ++ web/src/lib/stores/background.svelte.ts | 123 + web/src/lib/stores/chat.ts | 973 +++ web/src/lib/stores/context.ts | 64 + web/src/lib/stores/docked.test.ts | 62 + web/src/lib/stores/docked.ts | 43 + web/src/lib/stores/events.ts | 91 + web/src/lib/stores/execstream.test.ts | 137 + web/src/lib/stores/execstream.ts | 102 + web/src/lib/stores/icons.test.ts | 118 + web/src/lib/stores/icons.ts | 140 + web/src/lib/stores/theme.svelte.ts | 47 + web/src/lib/stores/windows.ts | 213 + web/src/lib/stores/workspace.ts | 408 ++ web/src/lib/tasks.ts | 62 + web/src/lib/types.ts | 136 + web/src/lib/utils.test.ts | 54 + web/src/lib/utils.ts | 47 + web/src/lib/version.ts | 1 + web/src/main.ts | 42 + web/src/pages/AppStore.svelte | 98 + web/src/pages/Config.svelte | 214 + web/src/pages/EntityGraph.svelte | 711 ++ web/src/pages/Knowledge.svelte | 267 + web/src/pages/KnowledgeBase.svelte | 277 + web/src/pages/Learning.svelte | 194 + web/src/pages/Ops.svelte | 226 + web/src/pages/Overview.svelte | 122 + web/src/pages/Settings.svelte | 446 ++ web/src/pages/Signals.svelte | 162 + web/src/test-setup.ts | 17 + web/src/vite-env.d.ts | 4 + web/tsconfig.json | 16 + web/vendor/LICENSE | 224 + web/vendor/NOTICE | 7 + web/vendor/README.md | 943 +++ web/vendor/SHA256SUMS.txt | 36 + web/vendor/docs/QUICKSTART.md | 112 + web/vendor/docs/assets/readme-hero.svg | 162 + web/vendor/examples/README.md | 18 + web/vendor/examples/canvas.html | 50 + web/vendor/examples/example.css | 90 + web/vendor/examples/state-sequence.html | 84 + web/vendor/examples/web-component.html | 48 + web/vendor/joan-engine-v5.standalone.html | 5592 ++++++++++++++ web/vendor/manifest.json | 11 + web/vendor/package.json | 106 + web/vendor/src/config.js | 687 ++ web/vendor/src/control-help.js | 207 + web/vendor/src/fields.js | 1323 ++++ web/vendor/src/glyphs.js | 1127 +++ web/vendor/src/joan-engine.js | 4309 +++++++++++ web/vendor/src/preview-layout.js | 63 + web/vendor/src/preview-thumbnails.js | 82 + web/vendor/src/sprites.js | 1766 +++++ web/vendor/src/state-director.js | 287 + web/vendor/src/state-sequence.js | 649 ++ web/vendor/src/studio.js | 3033 ++++++++ web/vendor/src/styles.css | 4273 +++++++++++ web/vendor/src/web-component-register.js | 9 + web/vendor/src/web-component.js | 308 + web/vendor/types/config.d.ts | 86 + web/vendor/types/fields.d.ts | 110 + web/vendor/types/glyphs.d.ts | 46 + web/vendor/types/index.d.ts | 636 ++ web/vendor/types/sprites.d.ts | 23 + web/vendor/types/state-director.d.ts | 81 + web/vendor/types/state-sequence.d.ts | 214 + web/vendor/types/web-component-register.d.ts | 6 + web/vendor/types/web-component.d.ts | 93 + web/vite.config.ts | 97 + 365 files changed, 61308 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/desktop.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 compose/Caddyfile create mode 100644 compose/Dockerfile create mode 100644 desktop/Info.plist.template create mode 100644 desktop/Taskfile.yml create mode 100644 desktop/assets_embed.go create mode 100644 desktop/assets_stub.go create mode 100644 desktop/entitlements.plist create mode 100644 desktop/icon.icns create mode 100644 desktop/icon.png create mode 100644 desktop/main.go create mode 100644 desktop/tray-icon.svg create mode 100644 desktop/wails.json create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100755 scripts/deploy.sh create mode 100755 scripts/install-webhook.sh create mode 100644 scripts/oikos-web-deploy-webhook.plist create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc.json create mode 100644 web/components.json create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/android-chrome-192.png create mode 100644 web/public/android-chrome-512.png create mode 100644 web/public/apple-touch-icon.png create mode 100644 web/public/favicon.png create mode 100644 web/public/favicon.svg create mode 100644 web/public/fonts/JetBrainsMono-Bold.woff2 create mode 100644 web/public/fonts/JetBrainsMono-Regular.woff2 create mode 100644 web/public/fonts/VT323-Regular.woff2 create mode 100644 web/public/mascot/LICENSE-eggs.txt create mode 100644 web/public/mascot/LICENSE.txt create mode 100644 web/public/mascot/blink.png create mode 100644 web/public/mascot/egg-crack.png create mode 100644 web/public/mascot/egg-idle.png create mode 100644 web/public/mascot/egg-shell.png create mode 100644 web/public/mascot/hurt.png create mode 100644 web/public/mascot/idle.png create mode 100644 web/public/mascot/jump.png create mode 100644 web/public/mascot/peck.png create mode 100644 web/public/mascot/peep.png create mode 100644 web/public/mascot/react-displeased.png create mode 100644 web/public/mascot/react-joy.png create mode 100644 web/public/mascot/react-sigh.png create mode 100644 web/public/mascot/react-surprise.png create mode 100644 web/public/mascot/react-yell.png create mode 100644 web/public/mascot/sleep.png create mode 100644 web/public/mascot/walk.png create mode 100644 web/public/mascot/walk2.png create mode 100644 web/src/App.svelte create mode 100644 web/src/app.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/app-store/apps/Notes.svelte create mode 100644 web/src/lib/app-store/catalog.ts create mode 100644 web/src/lib/apps.test.ts create mode 100644 web/src/lib/apps.ts create mode 100644 web/src/lib/components/ChatThread.svelte create mode 100644 web/src/lib/components/ConfigBackground.svelte create mode 100644 web/src/lib/components/DetailSection.svelte create mode 100644 web/src/lib/components/EmptyState.svelte create mode 100644 web/src/lib/components/EntityDetailContent.svelte create mode 100644 web/src/lib/components/EntityTable.svelte create mode 100644 web/src/lib/components/FilterTabs.svelte create mode 100644 web/src/lib/components/FleetMap.svelte create mode 100644 web/src/lib/components/GlyphIndicator.svelte create mode 100644 web/src/lib/components/OperatorQuestion.svelte create mode 100644 web/src/lib/components/RasterImage.svelte create mode 100644 web/src/lib/components/SessionChatWindow.svelte create mode 100644 web/src/lib/components/SessionGraph.svelte create mode 100644 web/src/lib/components/Spinner.svelte create mode 100644 web/src/lib/components/StatusBadge.svelte create mode 100644 web/src/lib/components/TaskContextPanel.svelte create mode 100644 web/src/lib/components/ThinkingBlock.svelte create mode 100644 web/src/lib/components/ToolLine.svelte create mode 100644 web/src/lib/components/TurnTrace.svelte create mode 100644 web/src/lib/components/data-table/DataTable.svelte create mode 100644 web/src/lib/components/data-table/SearchInput.svelte create mode 100644 web/src/lib/components/data-table/SortHeader.svelte create mode 100644 web/src/lib/components/data-table/Toolbar.svelte create mode 100644 web/src/lib/components/data-table/columns.ts create mode 100644 web/src/lib/components/data-table/pagination/PageButton.svelte create mode 100644 web/src/lib/components/data-table/pagination/Pagination.svelte create mode 100644 web/src/lib/components/data-table/pagination/RowsPerPage.svelte create mode 100644 web/src/lib/components/data-table/renderers/ActivityAction.svelte create mode 100644 web/src/lib/components/data-table/renderers/ActivityCancel.svelte create mode 100644 web/src/lib/components/data-table/renderers/ApprovalActions.svelte create mode 100644 web/src/lib/components/data-table/renderers/BadgeRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/DateRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/DurationRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/SignalActions.svelte create mode 100644 web/src/lib/components/data-table/renderers/StatusBadgeRenderer.svelte create mode 100644 web/src/lib/components/data-table/renderers/StatusDotRenderer.svelte create mode 100644 web/src/lib/components/data-table/types.ts create mode 100644 web/src/lib/components/desktop-shell/Desktop.svelte create mode 100644 web/src/lib/components/desktop-shell/DesktopIcon.svelte create mode 100644 web/src/lib/components/desktop-shell/DockedLayer.svelte create mode 100644 web/src/lib/components/desktop-shell/LazyApp.svelte create mode 100644 web/src/lib/components/desktop-shell/NewTaskChat.svelte create mode 100644 web/src/lib/components/desktop-shell/TaskLauncher.svelte create mode 100644 web/src/lib/components/desktop-shell/Taskbar.svelte create mode 100644 web/src/lib/components/desktop-shell/WindowLayer.svelte create mode 100644 web/src/lib/components/knowledge/WikiCleanup.svelte create mode 100644 web/src/lib/components/knowledge/WikiContextRail.svelte create mode 100644 web/src/lib/components/knowledge/WikiNewDialog.svelte create mode 100644 web/src/lib/components/knowledge/WikiOverview.svelte create mode 100644 web/src/lib/components/knowledge/WikiQuickOpen.svelte create mode 100644 web/src/lib/components/knowledge/WikiReader.svelte create mode 100644 web/src/lib/components/knowledge/WikiTree.svelte create mode 100644 web/src/lib/components/knowledge/kinds.ts create mode 100644 web/src/lib/components/knowledge/wikiText.ts create mode 100644 web/src/lib/components/ui/badge/badge.svelte create mode 100644 web/src/lib/components/ui/badge/index.ts create mode 100644 web/src/lib/components/ui/button/button.svelte create mode 100644 web/src/lib/components/ui/button/index.ts create mode 100644 web/src/lib/components/ui/card/card-action.svelte create mode 100644 web/src/lib/components/ui/card/card-content.svelte create mode 100644 web/src/lib/components/ui/card/card-description.svelte create mode 100644 web/src/lib/components/ui/card/card-footer.svelte create mode 100644 web/src/lib/components/ui/card/card-header.svelte create mode 100644 web/src/lib/components/ui/card/card-title.svelte create mode 100644 web/src/lib/components/ui/card/card.svelte create mode 100644 web/src/lib/components/ui/card/index.ts create mode 100644 web/src/lib/components/ui/checkbox/checkbox.svelte create mode 100644 web/src/lib/components/ui/checkbox/index.ts create mode 100644 web/src/lib/components/ui/collapsible/collapsible-content.svelte create mode 100644 web/src/lib/components/ui/collapsible/collapsible-trigger.svelte create mode 100644 web/src/lib/components/ui/collapsible/collapsible.svelte create mode 100644 web/src/lib/components/ui/collapsible/index.ts create mode 100644 web/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-content.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-group-heading.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-group.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-item.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-label.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-portal.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-radio-group.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-radio-item.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-separator.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-shortcut.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-sub-content.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-sub.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-trigger.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu.svelte create mode 100644 web/src/lib/components/ui/context-menu/index.ts create mode 100644 web/src/lib/components/ui/dialog/dialog-close.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-content.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-description.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-footer.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-header.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-overlay.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-portal.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-title.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog-trigger.svelte create mode 100644 web/src/lib/components/ui/dialog/dialog.svelte create mode 100644 web/src/lib/components/ui/dialog/index.ts create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte create mode 100644 web/src/lib/components/ui/dropdown-menu/index.ts create mode 100644 web/src/lib/components/ui/input/index.ts create mode 100644 web/src/lib/components/ui/input/input.svelte create mode 100644 web/src/lib/components/ui/label/index.ts create mode 100644 web/src/lib/components/ui/label/label.svelte create mode 100644 web/src/lib/components/ui/scroll-area/index.ts create mode 100644 web/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte create mode 100644 web/src/lib/components/ui/scroll-area/scroll-area.svelte create mode 100644 web/src/lib/components/ui/select/index.ts create mode 100644 web/src/lib/components/ui/select/select-content.svelte create mode 100644 web/src/lib/components/ui/select/select-group-heading.svelte create mode 100644 web/src/lib/components/ui/select/select-group.svelte create mode 100644 web/src/lib/components/ui/select/select-item.svelte create mode 100644 web/src/lib/components/ui/select/select-label.svelte create mode 100644 web/src/lib/components/ui/select/select-portal.svelte create mode 100644 web/src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 web/src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 web/src/lib/components/ui/select/select-separator.svelte create mode 100644 web/src/lib/components/ui/select/select-trigger.svelte create mode 100644 web/src/lib/components/ui/select/select.svelte create mode 100644 web/src/lib/components/ui/separator/index.ts create mode 100644 web/src/lib/components/ui/separator/separator.svelte create mode 100644 web/src/lib/components/ui/sheet/index.ts create mode 100644 web/src/lib/components/ui/sheet/sheet-close.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-content.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-description.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-footer.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-header.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-overlay.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-portal.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-title.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet-trigger.svelte create mode 100644 web/src/lib/components/ui/sheet/sheet.svelte create mode 100644 web/src/lib/components/ui/sidebar/constants.ts create mode 100644 web/src/lib/components/ui/sidebar/context.svelte.ts create mode 100644 web/src/lib/components/ui/sidebar/index.ts create mode 100644 web/src/lib/components/ui/sidebar/sidebar-content.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-footer.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-group-action.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-group-content.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-group-label.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-group.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-header.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-input.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-inset.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-action.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-button.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-item.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-menu.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-provider.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-rail.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-separator.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar-trigger.svelte create mode 100644 web/src/lib/components/ui/sidebar/sidebar.svelte create mode 100644 web/src/lib/components/ui/skeleton/index.ts create mode 100644 web/src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 web/src/lib/components/ui/slider/index.ts create mode 100644 web/src/lib/components/ui/slider/slider.svelte create mode 100644 web/src/lib/components/ui/sonner/index.ts create mode 100644 web/src/lib/components/ui/sonner/sonner.svelte create mode 100644 web/src/lib/components/ui/switch/index.ts create mode 100644 web/src/lib/components/ui/switch/switch.svelte create mode 100644 web/src/lib/components/ui/table/index.ts create mode 100644 web/src/lib/components/ui/table/table-body.svelte create mode 100644 web/src/lib/components/ui/table/table-caption.svelte create mode 100644 web/src/lib/components/ui/table/table-cell.svelte create mode 100644 web/src/lib/components/ui/table/table-footer.svelte create mode 100644 web/src/lib/components/ui/table/table-head.svelte create mode 100644 web/src/lib/components/ui/table/table-header.svelte create mode 100644 web/src/lib/components/ui/table/table-row.svelte create mode 100644 web/src/lib/components/ui/table/table.svelte create mode 100644 web/src/lib/components/ui/tabs/index.ts create mode 100644 web/src/lib/components/ui/tabs/tabs-content.svelte create mode 100644 web/src/lib/components/ui/tabs/tabs-list.svelte create mode 100644 web/src/lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 web/src/lib/components/ui/tabs/tabs.svelte create mode 100644 web/src/lib/components/ui/textarea/index.ts create mode 100644 web/src/lib/components/ui/textarea/textarea.svelte create mode 100644 web/src/lib/components/ui/tooltip/index.ts create mode 100644 web/src/lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 web/src/lib/components/ui/tooltip/tooltip-portal.svelte create mode 100644 web/src/lib/components/ui/tooltip/tooltip-provider.svelte create mode 100644 web/src/lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 web/src/lib/components/ui/tooltip/tooltip.svelte create mode 100644 web/src/lib/config.ts create mode 100644 web/src/lib/desktop-patterns.ts create mode 100644 web/src/lib/entityView.test.ts create mode 100644 web/src/lib/entityView.ts create mode 100644 web/src/lib/health.test.ts create mode 100644 web/src/lib/health.ts create mode 100644 web/src/lib/hooks/is-mobile.svelte.ts create mode 100644 web/src/lib/mascot/Mascot.svelte create mode 100644 web/src/lib/mascot/MascotLayer.svelte create mode 100644 web/src/lib/mascot/MascotMenu.svelte create mode 100644 web/src/lib/mascot/NameDialog.svelte create mode 100644 web/src/lib/mascot/actions.ts create mode 100644 web/src/lib/mascot/behavior.ts create mode 100644 web/src/lib/mascot/render.ts create mode 100644 web/src/lib/mascot/sprites.ts create mode 100644 web/src/lib/mascot/state.svelte.ts create mode 100644 web/src/lib/mascot/stimuli.ts create mode 100644 web/src/lib/mascot/types.ts create mode 100644 web/src/lib/oidc.ts create mode 100644 web/src/lib/shims/app-environment.ts create mode 100644 web/src/lib/stores/activity.test.ts create mode 100644 web/src/lib/stores/activity.ts create mode 100644 web/src/lib/stores/background.svelte.ts create mode 100644 web/src/lib/stores/chat.ts create mode 100644 web/src/lib/stores/context.ts create mode 100644 web/src/lib/stores/docked.test.ts create mode 100644 web/src/lib/stores/docked.ts create mode 100644 web/src/lib/stores/events.ts create mode 100644 web/src/lib/stores/execstream.test.ts create mode 100644 web/src/lib/stores/execstream.ts create mode 100644 web/src/lib/stores/icons.test.ts create mode 100644 web/src/lib/stores/icons.ts create mode 100644 web/src/lib/stores/theme.svelte.ts create mode 100644 web/src/lib/stores/windows.ts create mode 100644 web/src/lib/stores/workspace.ts create mode 100644 web/src/lib/tasks.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/lib/utils.test.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/lib/version.ts create mode 100644 web/src/main.ts create mode 100644 web/src/pages/AppStore.svelte create mode 100644 web/src/pages/Config.svelte create mode 100644 web/src/pages/EntityGraph.svelte create mode 100644 web/src/pages/Knowledge.svelte create mode 100644 web/src/pages/KnowledgeBase.svelte create mode 100644 web/src/pages/Learning.svelte create mode 100644 web/src/pages/Ops.svelte create mode 100644 web/src/pages/Overview.svelte create mode 100644 web/src/pages/Settings.svelte create mode 100644 web/src/pages/Signals.svelte create mode 100644 web/src/test-setup.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vendor/LICENSE create mode 100644 web/vendor/NOTICE create mode 100644 web/vendor/README.md create mode 100644 web/vendor/SHA256SUMS.txt create mode 100644 web/vendor/docs/QUICKSTART.md create mode 100644 web/vendor/docs/assets/readme-hero.svg create mode 100644 web/vendor/examples/README.md create mode 100644 web/vendor/examples/canvas.html create mode 100644 web/vendor/examples/example.css create mode 100644 web/vendor/examples/state-sequence.html create mode 100644 web/vendor/examples/web-component.html create mode 100644 web/vendor/joan-engine-v5.standalone.html create mode 100644 web/vendor/manifest.json create mode 100644 web/vendor/package.json create mode 100644 web/vendor/src/config.js create mode 100644 web/vendor/src/control-help.js create mode 100644 web/vendor/src/fields.js create mode 100644 web/vendor/src/glyphs.js create mode 100644 web/vendor/src/joan-engine.js create mode 100644 web/vendor/src/preview-layout.js create mode 100644 web/vendor/src/preview-thumbnails.js create mode 100644 web/vendor/src/sprites.js create mode 100644 web/vendor/src/state-director.js create mode 100644 web/vendor/src/state-sequence.js create mode 100644 web/vendor/src/studio.js create mode 100644 web/vendor/src/styles.css create mode 100644 web/vendor/src/web-component-register.js create mode 100644 web/vendor/src/web-component.js create mode 100644 web/vendor/types/config.d.ts create mode 100644 web/vendor/types/fields.d.ts create mode 100644 web/vendor/types/glyphs.d.ts create mode 100644 web/vendor/types/index.d.ts create mode 100644 web/vendor/types/sprites.d.ts create mode 100644 web/vendor/types/state-director.d.ts create mode 100644 web/vendor/types/state-sequence.d.ts create mode 100644 web/vendor/types/web-component-register.d.ts create mode 100644 web/vendor/types/web-component.d.ts create mode 100644 web/vite.config.ts 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 0000000000000000000000000000000000000000..86efec7359a9ae925f5da435a4ca3a9abb6476a4 GIT binary patch literal 152668 zcmb?@XES$3TdI_R;L5SXlkmw}@QKClgy}R4F z=iYOk=iZ;^#~3qv?Y-CDd#&}o-}}Dbys~z72axnkUs?0=0RVExa{>t?)_y%#;QIZ9!MyR&H5AwD~Ds~zg06X|U41h$u0-%pgz!wGh z0s!P(2!IT}Lmt1&Mf$&6rE`)0>;K1wirTU@0DzvUA}^!o1KG>8k0esVgF7CKWVxrF zL>LpPlRY2hjAjk7=%xx{&6k4mW|iiK7^#1@wzl@mtL6prYETH!+WfC{s!W^dg`DT(sl~kdq6k?Si7?8{sj4?# zkfx4SzijpT)fp62rdtyC%xUKNKpHpAbXJ}bRM=xP-f8xmN*s+8m)2+TgoDLSwO}Na zN$x6goyo1AA8ER)ExJNy?crqba&0P*b$)Hv-zj4WGGwAkV4?~T8p?LlG8$qezAH6{b-I<8ofgF?T*ua z%ds#8{s(UJ4&~lxV%z>iCO)6TrP9WHSdIUcTZX7-xvVGy1H*QB+sxs~hE@>^3AY(1 zA&cg>JPQUy!<#vq%fsd9-xI%nIZ>Zn4}*P28J}r#Ex@99#)_6rg=E_9t6y7NySC8k zooMbQi8d$|v=jzjOaFL*Qjtt3YPtR!{>cnp>-TLwNA!#IxD8I6!XX^O$kE8T$`|_! z#5>k=C;Yi&_~Caq7aVbhR5h00QEj)6#g1S9{DS=>4Px)<^3G@3ZunFGM2VKJ-Nql3 zyNUVtzchZMA|NRohN%->``-}X-TtGqKF{N)b@>+b8b9kriv)I}PZf;x@PFUy|i$;Kt2{4&ak zz@-@o$0XIua^0R}t5=34P&!&`zQy(2n-z#9=;_2Ikj~=uJKdzS{u!5oLoHgG{n0xP z4fjdkNVb^m-fX=a*sk}fj2o0H!#3kC{@?cd&}0cO>Wif8&;0gr9$UY9-=9<%Rzq`; zv%ilRR2XQuLeKwTn{@_09ZY?iRHT+RurZpG;?Q( zojcl@uEum0?E1a>^P~P6>N0vQt!$2DfX%N$6`eXejp?#DYO!MBt*I|2(UhiQSFgeV znOn|qlJ}Vg)n;$-r*KFpBfeZx$woX4dvkIW$F#AX5$^8;K92J=hJEM74r=GWZSQ6X zQ0B<`E2Z#oYNlA5GIkG}TvwfLYMd8D$)y6@bi%$Sle?+pob5~xB7%|MtZruBTpXxX zo3$sK!Tl1$rD1R<%}hZJ5>6uvlTq-3l8;y4wKYRwDzFZhMafqSE%k52144@s?g2OF zZ{}CGy|WGfp^H)yHY?2~1!PG0HS7sAg~<103b_!xV5B-cT2&%$=m>iFD_Gp|>Q`YD z=ZkO3uG!8fej1JH`VEU|G6aJ2wYD)fc6PJxEz^p%a;oCBCI_UUM(sXFE7462!4LD# zoac>1_(-|U)Ca;?dLijzwVY2)8e(5CMCp3*X|J70<9YoXNIBjdXIQXl<#YD(YDngK zDauk?=NWnOLkPn2&UP?0r9X*9Nh6$!M#PPg4HqXnxS5g85y|Qrf{fZXU2U%M(yHNx z8K_-s^>+E!BW!QEJhNl;V)5$p$rCNNoAbSauWphP7uG*wwev%SuOW+23s<=a9M9~N z{e{+nMy)ANTO6v=I{VR<5jh5ouL?0F?4NnPj(wJ5fSDcNvu*NWqCoM05FjKq6b+Xm zBBU=}Ue;7Z)QEvkJg|q>5=q(? z=ZQ!Vf3W%7f#4JCWtSzg48XSklTJ589b67YY<<-+>0<~Pc}n`}ew{1{_->&ujJ`YT zg+#&)-+n*I4PJf6Z6U!%2gF4FTSCyu$Z_^YTdymf608GwF-2=*xL+faS%cf=UwVBm zo%pz#!UIWP`yPS&InUv2`?tR65U$!M*lN*|a+#FhIj1KS!6^eJj9*<<|LZtj?5(k9 z)~Xhl^8%H--G*xV6fs$~#|Qa{uA0hO_S!SVEg~aAj)?I2aCft;st1r%b5q#bmaAW3 z5bOrX3qZ)NJLgkta4hiq1S8nY#>#YA7Vn$qnz9(1Ap;5X$RqJIhYA1{HEgK}?UUe} zayJy2NJ#C=0!3`YTUatILsu10r$Wb7-Bgz@oN07fEc|ZOG(2#l_T@#rX~}HcJ9i!T z%N3xVU}UdNeIcGsPH%Ro+Mf>>f@8omk>K$!!}B@4-}$bJ>ZCs*zzauiYl8EnY-@0{ zpvt!a`1D&ZDviFDI&&CQ;+fiT6Ox}L7IXfE00tjyEUIyXW(7_ zdlu@B@$$=KzK`16&CeJ;x!_z)?i+Cy`F#UT5Lbw5tlYRfF>zRtuPr#S8@!jJwF?x> zbl2s7dQ|bUA@Pv)WsASlp|(iZ^!kFV;hFl=Ql$-3(FHNeVy&%7+5G!_WsDLmf8pBJ+Z1HN@!aU;i#Il_QPcD9pGIoHv^b zxu1u+iB_U1&go*xqf<2d5-!QRLgNVKEKulZ=`FwM*A~PV#wjC*88^Mn8_JO~r#oQT z9Jdy!4o>Tf=U8fsd#(N&rOhY>c&|gAOl?;M9$womV10;OzhU$buo2#$F3xfso#a-a zj6wb$4=S%`uUqF*4sqj(_7g_x(e`Nd+SB>xZWiW2$zVWfnv!BmtYS<|wG76zx$SpW z?NkTuk#NBcev%M|Z5UpHY6^Q9_0QPjtV(Vi?5hVbPz^4BkN9_cbuti)bdu8U`XF z@fniIqvum>@b63((UjD=z(@fB%iuP(d%hV?WmqmLWlT7o(!n8zyM!%@YB{NK$R@4A~A#8y&MAyBfi7O`3g z;EJywlsywBC*v@t+mI3*QPJMI-n=IAcT3Ji7csln3KZXCgmw4^-umCuv?nOYRRody zJ;F5=1x@)XS&NWY);vN0K(zh;0giC68T>zh<9A8RLGZ)>2yle|H{keq20%Fu>J9cl z{XcEgXc92 zg1b!YGsJ!_Gdnlu-p9j(0XzGD`C8pK%nnq}_c~1MM?~Cbv-oviSHr&n>!Uf`Gy$gx z{)b&?*Y7s-BG$G_a#9aEA39K621gmv%zR({@tS*6WZoHAJN3eG%l?DFR6J9fX)RMW zr$7>9eI%RX5eFLA+4Waccd+LK-8bLfo{Sz}EC#8Wm}GL+Adqy}Q(pW^XcJF9y4=VK z8ZZyKV*&wbN-v3tx`Ej{+bs|%CJ)+fO_Y3e`Zlhpu~Q|0%=4>6Ba>OTSUti1^E*W) zpERzQ$x9x&v`y4sEMTa3SVyB$4={p!?}NW?J@`s}fBg>LJS8XK2N?W7oWbX{Yh<}5 zw;4|Iq7>YtUGCsLUEsr=_cEmlkN@Rcaz2|#)#$NIN7ML7h}}>+9fML_GT1S*UeWw^ z;DStA|M1W2iZ3>&o8z@VpZN7S%X6Y4;9@tsb++K*z@AEC`nGF^o734r67~La6}O%= zPV(A8E>~g(*ZPxe$8u$`=O*GIhg-Pfhlr3%Z0*ermG98T z1+UB>INSBLT*z(bvyOakm!z+b2f1Ss%yIob(?p;fn7S%7A9UQ)-Bb6!D zNY-zP4zk~I;-2xX<2CjZr;E^;t_$!dTh(w3{u4Mkwu`i@lT6 z6!f>wx32^|FtO;aVlUhw(SE(YFw@xMEpJ{}fYgrQewty07*+POagUdXvSEDE~( z6$kH*xtk>*>p{RwN7Q_3;`BA#34Cm|SL>OBEjv{lgWR3|4#$jGa{I{Yhu}!>=csPW z|9T3}OqCrJ_DwqiS}ln)y+7b# zzcJ_i4yz879J*Kv#T`iJwf;trv6Vn<{i%VwZir9kg~zypi4LXFhf2F!tpeVu7SA1K z`z+V@B=YfjRK!g=^b-|kg$V(MEETg-` z`7QVsM6#EIT&?PRP|89EatrauAaHb4B>dT2I2&m{n=*0hql<8OuTN!C^!Mb2Ynx;5 zYwF+8XmgT;E4sTr;Mj8jO2$u`Qfuogs#;V%TfZfXNk|$gH-=W2 zsJI{=%jnw=#7dIL!Y`ZHKLD$E*~8aR;*Z5;`<(CTe*rl;i(&_D?ghn@;!3bjOGF%n zH5Tt(@Z}H40BUvo@8JBbQot7YN+Imig+kg#Y-pO-gG=Q+t5pc;vA<5n*Vv`wswqVt zl0$r;B^W(HX{&{h4b$%2-y9fz=f+ZroLcZ+eEIaM^u5^(|4z2hXC!ur79Bd?vvPSe zu@yX(&*Qs(`58Zl106qX(7l%7Q=~EoB72VFH)U*(zW9Y%^35_e*{90BqobcR5dzig zSNC@}jx{j04NRZ!z~)^+oTw^})a{{iQ}fXeN`Y2lp;2NB(~g_{*6r2rl`2cgx|-u5 zCRm0v2uxx`pVc~cf+m6m3${T}Z~cbeBt~lYW&H?$h$SKvSF&kpYs}gQIs>h2jBn81 z42MeJ)_qeUS_ysm>yznA4 z&eF5?8RUoG>Ib1``|;nXBcAw{Qq;`wypk@s-mnC6)o**_g>5|X1nY35{qaNwy_{9nk5Eo-k@IPIxZpkaH2E(=a24?xF!d=e&LH)ED#b~6j7Js!`IAqDoZbwsiIm{>^FDuHY zMJrEl)@?#V$k_N4433qWCM9$zI+g&Iu-){^kKb%|hiuWP7x}24{q{vX)vR6Jm#lQA zRh*cftWbch@*OI@sJ$5pFORT!6w4TzHa`0i2x04YyZQSdS9OUXFG(&7G8xaL?^2j! zA!zexX$g%4gQ*X@CL0|Ema-st50@R4=A>w3)E%icmdW{_(^-@;cD^)zE^cc!Kpr6n z5~6-nlH%1(?p?7$HVOa8**sxExz^`O%dUWwV8QoU8b3y5HAypZsKPQ-BC7-~nvEURI?<6SkM{sC~YM8TMSA{p$SZk8Q zw)efEy!5X!5Lutcz{k1kA1=xI> zYR0Soy?DX3!!rMD+I(>!Gx9{T%JT;hdvBZe(qOtQW=^?EDWJ(KVWoi(z4zHy?3%dX z!9Y=KVD*7vJrTfKBj8n;?UNu1oss^fT3C4CF#v2f77~hdMBme{Xd|oR`~i@|5Q!Xb z!9!Y*(X!>z1lS|ZD%TABFk+(UqjhE4%i;yX%-_`iPW)xg@{-K53NNWJ@7qTUVlGFW zTpocm0{A$kz*Zf}vGRjm*cH}k0-RX8s}Ql95B4Gnh{fT9Cn9+Usq94*9>n_5U`gl4 zU?am>B&!gAGB=YoqMqgBvn|zOz#(G~%E5*3FFikrg{^^j>w-C>5ftXb3vMcM7a{`+e>-{l2gr~&wg9@PRF-47JNin%FW)v|SGJ$e8ocgVUvS;8A736~ZGqYo zz%{>Of5GO_T~~cJ`)y*~?AL(l`8XT0|8YNyVAFV+POJ>2^{;XWzKbSBpNwk9$%xn$ zHgvdxQ_C57D7kqgJ!;NO(d8HXx1o6e(^B7$mvM7u;6b&61M9xV_p{LYO%-Jlk>>G8 z|I$}0^|#6iF;zse1oYq<14MmAi8DX?qSeNlrsc#Z^$2n&=k4H(?@IX50jtOa-9;DV zrej*Rxe2Im_Md4Rnjx8^TPJOzn% zm)8KgbdxB~Tih7UmWF!`%4~i>H0$NSj7>l43|s6|vtMJGXR{iMMqA=IZHL{MugOa2 z?q8L-qb?PBqIMDLVti za9kxa+nokc5hI2cslDeDwm-|3@c{BancA+mE500Wz%ag=!*O-sO;Aj!#8xumA92(> z;^*cI4SM8oTm35V!nOr_uOGfmXing-g{npRQ4q(&cex$vn)N5W1zNZSP;<3skah=b z`DJ?Mf!4Q$&GYOlISSm@=X-Oo>Fkl0AbctN7)X)Kj%3=BAhL46PjWoLNH!w<5lkMj*v7{ zx2TawW(|tG>ftcG3rv_-m{s)ZM=7%^a3U-%8+95hfBKLS$<4!Vf(^~%S+UbU7n^aa z8QQ%}3}BUP^_Y)ELs$qJ3 zpd*=`kZdoqn|0+&3U<`BlOid}8At<$l`R#XplL5oZ?^oYvmXRuyUq3K)C2IREM*FyMxv*W z57x(`MrwxR#7LabQJ{B?r{Ga>GvZ!C42!j#-1I+51qI$rnYetxZ?p?j3+*{v7K#OQ z4~HX0rjaYXCBq7?Mlic7Zq($HQ5t8CzJ2f!Y~FS9h*v`8k^iFD9ds2`_ykO;L`iFQ zs#*o9$RBKN{(U`AmT|ygMLyk`=Kd|QdlG45egi%F8j}m=q%~crlny$@Ukd}?_>Xh> zVM$vPynW17;a~ZE4lM){cg?lMyX>(nU~-PuOR4PiGQ(EP{C!Tni^HKgJN)>+UQ_-s z2Q`v#yap9JZFFgnwELyPQihvtfE;ttK!VHzJQM8MctcG94}$rFRLj%JJ~x~A4x%w~ zA`u``2Lx-8 z-(M!NXcDPu`{`=0NXr7~WH_9VqbF&tc(%h8 zapB(;7U3hGd=4z*cr*&x#HO_Q?n>aYywFlA9TxAl_X|#$Aw`~(6moe|NL@Z zk!V`!Cy@V{)3(F-S*Etu2YJsh?rlOse)u~XrjqBS9O-`GK}4Eg=q;U_Qll0 zXT9FySkew|6#c7mLxe+H3$2Y)9EsC321Qiof{v)L4nReE9O}3pucI+cofv|Hz$=Xo zp$j3qxp0BS8AU8%(JBi58QMGpXOJ^BL#OeW9x6-_qhH!tVh1?Rd!2ggn~9PiIy$=J zq9{x*R_OHgFFsAQ{u?D<3I99X%a7GMFG_kqX7j%@oxJ=2z~%SuXFeD4ydba0vej=y@r zZ#3XyzkO;I`(VL)@FD9?%I~0VRFci`mpoIMX^V%-e<%xLUh346{ziL?o#t=;wi$y0 zsT#GYhjO-rpTXUR)PuIlZKTR9-+j|wax&3bUCy2Qzri%E6jp6n{YoS4qT=H6Uqxya zE4?ulpr&0Kf`VBNF3@zZXoQ)JVM~h-_l>i4_8(_K`Oo0^&0IqnD3_Om3U=wq#%Kk| zx@q1W1U=|Zo4S7pyjy&@%gKi*K!fG+Er2|ifQP$_YlX#@{gxfQ{rP77JlSx)Kee{{ zzsvOnMD(i7UV;*{=1af5`Y5xhltsT!0NJ1EO5*}hBCiCs)e`ij`DXVPUWYI$5jR$~ zafLrGx47LBXz<}goK~0MNi+YloU9L5pyz64i|cf>1dxOSZ<{D2{hPoeWpz)c;V=I7N{x9( z1{tJB2t2wPwii1)*-e{jIJ|c;AVXOqy4V!Yl=o)7%5ik4kbq?NO9_x=7M(gLj%o>OKcSt7loA3Hy+r9tF@#--zbgQBf(CrhR2vkUC$D~K&VS3& z#e-oW4JGEOZ4$MF$NgbzXa?3c-IJ1-HFT_w=l;mMamg%25LugsfaZY;P-EhlqMD&= zzyrEEs$RCTC;BD6dsRpc9}s~;Snw^-#Z}}E+D_u0c6EiJFKvz&l;vTp2vPE&$61( z1Y7E7$(bAvB)?h>HVtHe%|rmAeM6L@ug@||3Rd!OKIX<*$i!h{fdg&=z z62Mcy_jD6~qTiYx*gpR1lmLY3ZAYQoix}a(C4#WyT4L7>efA9-SDl2u1#K6OO9vxqFPKj90{&R9_hXKg!Yim4VNG#BOH|ws0^G;} zq+D4S(A81@hdZ!yXOEqf2%4`5_W|}1lV!S|Oe@M#vC07B&9m2&$^mD;4Mh)0x5AFw z!E>Z5z~L6*7{rsoa@=n5W?_t=!h99PP`xOE@c4N0N-3c$;vYmyd9mD+ls^mowo zOYfp-fsY=1S7vy4(6(TBef1A;sj6EDrB%a(6N+EVyZbImlr3+3P)upXd)z(Sc2wPi z?B$4REhBKgYOR^D-Toh=(;t@Gy2;a$l@i7T+LE|CF7rpY5=(;&#`-rx;Enjw-srIW z!A%cgbTy!oZ-vSIewj9D6dJrH15h`dip;;Xrw7*Y*+hDt*zsLQgsMvtLKD#-;E5DV z#`j_qUy&?QS~$Sh&GbMDO~i!|@M;rDgB`2e4R!=YpRgO^0~08q znmxWQyD4+r2;NFYA*3c?)9VLYR|RYdB)xGdS(W#d(ppKOPzW9^2|O-BRTNSaa8a&b z(Q}Kdpma|H^$!D_0nxZBlSECxY5m>hp=H^rH66o22z(STH=6W6w_Ktc+hQ7{4YvO8 zlWh7pP)QR4QAm?FNlcyn3r%hpA<~S{F;@@jY^7(A-Gx@ey}#`yLl4TE{os~F1~?_W zs^BCNG-*7{EEuNVF~8dx*uzA^$CLg!?&aN;#Hira4LS-;t>~#wVM~&EOxe%n`wu?@eQ&ELBg60Lg$5| zYjXCICaBWuAFcH2m+2JgT^;|^&)~D21eFbtAYE@2e|Nk&%)t2%SKlH=oEEt5>5U<+ zFY~!RedW2R8W|nZC zPq^26i|&=X5=9m5=ZZFpC*a84`9KRUaYZ`1;5;#c!4I;MF-f@`WOH>+!FWep&mi@1 z>#l+0uVfw)PYcCApp(8a^3ACYyu0Qgy8oswez4$Na{w#)NmUYnLBd&@<}UbVR*(Bn zl}=GKXot&T0c4LlZ;#bdrgA0|nbao1*)^Fb7x66|K1gS?6lM2I$Z|^*MNW5pD8s!7 zR4;Yzev0iC96*bHw%Ns$fC5H2*oz8gjZdX+{|4UeYJOkD*(!FNuF|Ioyv)-S|I-=N z$;>;C`&IC}_4;~AkDuL0R+(FkY^1hgk2n$!f3VPyz4{}+Pyu$U)WoIz_w8`64;pCm znz|Udt82z87zRbY-{8Ve?1~a8y`sq*@_Kc7Q@kbT*B6sU4iHLIaU#{?&l_neSm$0s zw?7HFR0ZDcm{lTw z>*Y>`akP2qbGr5mdcRgMn(m?Q_uQ!wgjqeb2>tbuL&FP)&A&lL>i3>^jZjs|WN&dh z-PArBH0`0$&0!-8@dfXTe!K6PPUE~=OpNzZwi<6rq~qZLM>T6DGCEnygdmnAh%5Bj zUTLp!q$AVs)56PwKw`Wc#2{thgDM+H;b?WY{AeDoqYsLuEueES`Ll!tyY9A*=$u23 zrH*scGHU;Y2Z?=v*ZKkZ)zQ(P&USELE>nRkE*XpMtBo8(5P3}2(R%3MZ{Xn}y~QyJ zup6AWFH|}Tx*9ZZS#g#T=DfDB=^e70jqvN5w(|h-eB%ffqu5xCOw&inYvspCzEK$? zuw=?Y{J48IBGgUddb6N~pK#Y*_4z62%G#8tBJt%(B$%U(~7yG=R==H ze-8ul0k&B=p8BXm>jrQr3C-px1W+;GgMxp#uCP|N=szDpY7(T;9RL*5-$t>6Mft=C5Bfo+CiN9Q_d;EfkN&d8gXeAYOUtK z`PHd>h>Ib}Gw_(u-CW4;x6?O4ZTNIN){4^1esK_M%u{mVqwDv`(<&v*;5RWp^*Qy} zl}a*)0|SdiYNThQLH83=o>#HXxRRH-W}2C)rTfnd;R*Iai`x5`K1SM)MoiZHfKp9c*PK zVt1Wmv7h}#51D7A3!d@KZvwt97kyN`I+FSsOK$B*M@-Ti|12zLk10utA`-FosH?fO zpQVm4MXn2ZJqbOQIz%9gx{vMjyxMSQ@#Ug}li?Q@Sbruwtm9`3w@CxRoZ~;Osja6q zbipwCg2aS5*>i?ELNTx1nw0c~KpdfJ7APL_EVN_4dE<$|o7rdCN;KcK>LikVv1}HW zby?>6a*n!agZ&QL4YdzL<>?#4R|=uFxKG44Tf|n*>t&J` zVuInktWeoMl{SWf*T4}T$?@}5!0^W#I?E_=NI3`Z2lhk>Bm(Hr6PmlHDm=+^pFvyh z7X^{1&lg)0N>7by5C-1#(MoQocu20C$|1m8^m&qjqBIqgv1fkgPSk~1jU?TBe#hHq zVF3NN*c5xMw-w-ef*%*R=cve@_zL#o>PwQ%J|2qZ0*QeTfm*PstsSl7X*f{( zLow+4wrFxP3a=N2_Kx}8wxMVmt#N4JpJhvY3Vk@ZwmQF&vGr~vTrB$CIw`hy>Ll?GovmFt!1ko9EW zMFL#>LX{PVb-l#6(JM${G9TDF7w`7Hlm!_%&jY(>$gc%R%5*<@QbzIvxNQ*vdp_{!EG&Q0KYNSkVp}%6AT(Xc& zPoxs{C}pvSBqM!(m?YK~A;6l91^A)J*ZpM3|?+2SZ^s}Nce&+Q0A1w^Dsi70E^CrCxWy7We7qAh=f(|PnNVkrn-_oPd=}^v;eksn zT3EsU^6rD*0|RAJ1IqzJDS-nL{EGZ6$7cooC&r7F3u{QzOAc;w(Pst#2R$7Up{z6D zN>dq}`vMZo45a4}=v+Q%qO;Y2)?LCADJ+B$1PzC3j1h0$(NK~eXAHE*BfyqS02$4C z$$3YT>@2tYBKYh=jwKl#2uMI?X3NI8J zv!L{^wW<-9y22{$4fq{qT{pCh*R;9(2%KH>y(g4NN}sB1Gk)Qbw|v zd>D2KT=k(~rJh|Ywab<2^;DS+JzWARiQY3mu|Rq0#O)!OBm_Rd)=5-c!+tFMXDKJ} zx-9ly8!1-p(LVErSK*3q4%b>L6ogFz&kL!+gUV^y0a?j0q};0K=2!qf693B<&}&T= zXnT;??7m^JH`jvfMj5VeHwq^l6aNlMZMA=2AjK_?wn zB(aOqO6$sZCD%=z=ZxQ0p!yFOCAGV60F zf=^Fy$wQ77PD}a_iDo?yR2GB7p!dYxB=CDIb}N?kQ&brwIe^s~9MNZbA1Sd1-3-A< zgE(a-bI_Rq8o;uPVA^15e4zEmY;%#fS9Y?7l+C@ASXg&Z1Z9oa-V0AKPd-FzI((J! zEdZq-4_g+)n-&JwW&(32-|+~{JEcbg$u~R`PkPs36}3h>67>3_yHZ*y$h<{Hk^#~m zoG4$m`+6E)6Lm=`^3cIAmx|ks^2ftK(&lUDsaAk(8kYs1sHmQ z4za;Z^6o5L?z?XVO;<15Rf-`B1afd;KnyKoq_k4I;Jr2nBQFud!|XH(0F_oGT`$b+ z#)2zb!SJN=6lwHR)GI;BMbm1}1Sz*`W~(I579f+^p-^<5Wbbx3d2Gt*)9tui)Rx zj7WpR(MKjVz@+fQDP<)tz8@Z-h7p#YEE|q-B}j@$lORT-MM+OXcNIJ8>)~(l)POCG z<;f`)9AYsiv!4_=Zm1zy47|TG5i$hcvqL8g19v&|MmMJx@qinSPwLfnJ8wIkskYUS z5J;>b>BeiGbr~8d0h0zetN5A`(II?BKp>Gh*mIY!R=u(Hqy^#NR=fcFHz)!m>gao0 zh7}`eP-!K)x7Moa%}FA&CxRJcG2Ks~|{W-lw zBCs1k;F7HYLj<3Ld=x3d%wjZ4#QFf;T9}d~(tO(cG7)evNz!!%)8>;eqhz6fXM9=1 z4K&c9h)+m_~2zn1sf(YuTSWb<_cHLL?sJ^rS-ksrbz8 z8oSnHe!~(8jRaAM@;pEX*w0R#*X8@fk}-$Z!-_>K`!~E+W?7$T`{*Y4{EF9jIfpJT zH@anliST$6NccNaj&jN+NUpdT2DOPNsVamkXCtZ`J#F|lw zXZ)OO%Gqgi-2x!`WQ_@)N0l7k$(T3zb91iThQZgI~Y4+Nk;Z;CQQ{k-_$mqBC+MZ?2MQ56Cc$4We4H+}&@PLr9 zrK9jrqm{m>FL$Zv7cJT}&L-6nT8?n}Q`;N)FM$}xD`+1a`GppzEI!sc?0TiSMiiXE zh@Kb0WWa!ytELbN>0@0^mHG6r{V5X!pxtTo-kbf$oSIRkAo>=hU1ERG^DXEl1|qI8 z8lM8F@e+RLO@S|kGW)>cS_;BC9HFm69#0i3w|`e)LTqH28*m-eUZ1pTfQF-8B@6m< zJWAOkn#bj)ZVc}6BuS^j;Av9YMQ{BpIj;>@KN1UYxwaf#xK3Hx)zx>t+V^EW1yri^y+(82NN(Pm>7Jxtd7wPB;q9-Vdlm< zAJl`a14*H|H?;OePa_oGtW4cJoGD~7`g$!p&hKWg@y3_y&npWJJOs$QoIb0~xeJ;d zZiHHRs@@7u{3_LESp5o{AY=3mXl(Q~skMnZ0Jrx{L#3%KK+EQj3S>~F(25RY?gPNZ zUilDf{2}YL4u@gYY5Nr}eFqDG6kL1)Vb?D&oJL7E&BP11xL8j>!6fn6`_Xql2Ljn@ zLO3T+fCkV5Vck@IHeFbwrVY9Rm~imy8So9}i2Qina@Tv9Bd*;wn&@7A5vxyrCc0uvuN2&Xn%kEsz zR;df31a`b&Cb4=#BwF;oFJy>E28#>6Sn^OQcq{>l$5+;TWFIT-1&>GaY3-2J_YE!9 zx(M4?G0SG0h;QfPK`L)E+(w4yp=;s-ijeyi1={cja}R}ZD$|w`l|2perS-|$V@4$K(0nhEp%9^+T+#eC}$uRvN1^}`on4(fqzE)Us zasb{(!t~~c=bXrLfcP^5L^7MMhnEE_%=rNug7+u&MerX6t?g&cgqkc6eA>U?Ug^?e zHlaoXMpW`~jazJ8wYG!hkNN9?NKQKj^u`1R9~k(SnRL9K>4G}Ngh>P$i-NI(B*jz{ zI#?d$jgG3A_ghcTO{bLf2&6UCeo|z$#TkxL3nS4E4El51<*1aIcXq5WWdtvqKpt|4 zEI@?S*Y^-44~Nov8QHPb>vr=U zk@MR@y-v}lr3nB71Sl!~20NLxZK9oh0a8A?0*+He9JE+3h7#c#pT*yG`#X**Rkxpi zsusGBMJ)pNS$|S|0XpIwCNcUl_)D($T!J_#Bj0b8H38lWo`&fzyH}A`UqNLD!*oSU z@f1Y#a5%AaHvH;j!@j5I_XAibW(Mk+(ZqNmEdmdtv*;yjAhXo^SDM6-vyZ?C34wta zeazg(KLvwiU;(K8!QJOBhnt|?h@t_mG* zWC$G}!=W69^iYpd7DaXhC|%UaE*M!l)S4@}?|~#akYFPlA?&&$|IA0k4Qs0l85L_x z1S7puhNPB3<>3uTO)>D`T?QPqU)Y>J;J>7UKSQz*bpXvWaUM+$TD~CEtw?=pLgA^D z-)_kA@jFI7yqu5&=)VdAQ`B!o#FOtHV&9PZAOU!aOVj2-FQ;*aVDTmoKdAz)w@UQE z35-mQE6od*R&Dnw<~~Yu9I~!=@`2LQFj{Q1hW z-014)W8Lc*WAfGuhz+{1GfF|w^TXJNgJ=}I5TXf9B5X^u&lq2e3qg9Y9IO{nJm}i9 zw-5LMl6ZEl#|LwbTG&*=&s*Q+-)5MceR^i#FM9H`>n`k-j2#LsGK;P@1D$D!CUw14 zFNOk|7ibnJ^mD!T%Ri}ioHpQYKTQOQ7+PY}Xt16ZP4b^mg&+|oEf(Jp#-oQXT4F^V z-SaR?iHKdVZ@QQLaFlmA2ekew7!+pnff!T(q-T0RVI~}ctU_2-!&cd;2{3A=)3ducdiWb91p|hG$=prUFd46Gx8x3AVVMh5iU+t_=~HY?u|O>$6+{@jdTCncyC4&?V>F5Wz9 zt7>UnijAZNiceZ+a;4*?Yn`}4-o>@u9uJBxv#8D#h{hsS$KHQSV7!q>F}{`|Kvs^R zKc73`K+PLH+V`79B#>^34NS zwe*JjSVnsqoEI_2EMrAse5+uo)5(OE)b9@ASKpb#eE<<8P;H+WjLa}Q39&flAAJCs zY<3bMf!C9L4RhUlJ zA&IMit4okD#LexFfJv3rYr&G~38nuG)8rdk_qToVbh9h?|Fp1-T_FUB(P(u4%jF2) zE1t~2PXr<6L`0T7Oc&W2&$V(Szl2&jv!aK6j~rD;!5FxYX=3$4{r4CLpZIRlJyM_& zzwI}gHR?0Bc2p$*$`@c>ALAYQ7A!OPE||r8q+9de66B?Qq4!{M)tM8dU&sp;lYGxr z33e3)Bd1lEKr>O5T6caDlo`QL=iJU@V;-#+LK02k$woCH$WM z4F{+kUMa(m4JL^kz-n$}!b@0|69|;x_jh(}r&u6tvIFX=D4`PU2&`ptf)5N(H zaYna71y0Tp%glX(=iArdAo%TmhGWK6ru9MK_+!wm+}BqutaA23$Hv4LySUZ zseOW)FfRE%;d3(BQ_JFAK_Hw(;BQ}SqsW>xIE@qg?`DS@O99;0ZnLr@wXoi@i6GhWdTuhwoW1wyYuR*s>-hOOYWvNk|g1Bq<@3 zCCk{-LMU5hEm?}Pq(o&%NDE0(Sw?nAB5RoEnm&D>^E>A`=lSdT>p7j%ABO3@+{<-e z%jbWpc)id4p^cHjS%_aIFt^c!O_vFIk`1;e*g#jn%WlSg;o{8H&s#G2`}Ptev*=L8%TPhgaZAKU z={SlSo!xX~>if+Qx-z0=zdZ8op6AkCUvi&^I-f#kLfLPr z5MxFj-}&1eb>cH-=VVT1T=|%H9oegFK6oHsIpl}$R|eTl>;&=T8|`e^?+1vb26F4}encIGu^X`8USp540*? zSzIx<3o@vCcbG^PUh*h)kADYcgXq^nxwnqwUp%wE8uIiMbA);UrPy4kvk=cEjxuwC zdYN0v{4IWpv2G0Fn?5nK1P^I%Ck3MF0>B?tuUx*-y2*G8wOx zp1_k5H6Bvr>UciIItSZjQ zNpb0sIA_GL<9}fp&oEs2s&NK*j2=aokOyFnVp{uqCqtys7kIV*n`JMesXfuYKt$N_ zK){r#p7e^`i18yb$n}iMJGRLVTq0njN&e!0aUd@Z5ZK7>G%tI+1pSp?CZSr3YPrCj zw~k04ECZmP-d(`DF&v3CU*AfQ;>V6FUx*kFtU-~RvDBaRoafof7X;~s@>46!kL0J!o5uKLRQRG`*_%Le1w@zQ96OKl$Hb!&+R;~c}j@O~y6 z@H1=g1`})|^Mi7)G;`~IXA)W2^LfBqe2<=4A?J~kA&h>*_0i;bE|*VRpXtWw0*}G9 zk?f;hm>JfHrB=zL^Ba?;!)D`%)IE5CwRe3}skuAbKVqqfWw$zjg81WJ+lOBKVMrge zLxl3-4E+k=VK`s2CHx^DN_z2Z0s5k!0P5_Uw_4DqcMRp0`f&#Z{V z`R*2b<7DW8@sm)$FRT;#pzW$m5-O-5U0UP(gMpGz;X*Rg?{3A1uKsM9S5mETG`YMH9>tTB^21o>-5Y!9 zNdeOMg= zZ;xCL6OTHUobHat+~6WDA9`>sztA%K&KfP)QcMHmr*b8BN8or~`t9$`?W0%exF+fb ztgPDKb^soZ-eD3ufA_w}aX zrQ>{!yjk`*dh-g{WIij-p4Fos$9KpD zULuRtrjNJ!X7}-7sYj+X&7>+LY3rd$dY@cdqwV_{qMA5K8JQuwA9bh%zELujC1ifs zlK>TRb^&El_Rb(nCy3w{aU3>(}oXB?_MY5D{@k-{l5t^3TE^vR^|q1Dx~DJN9t# z7#Y9_OGuLMxh&k;Ic_z6G$$oebK$VwMOXUZttFQJ$7h?hqTaMV|8Y37B2ho*0af+q@T>Q`;I3~ zJ_XE>EgWiRHe$P(DWN+)awZ6CSr>8HoVHiQvTh1l+==~oe(wk8&XC^#{=9nDLi>>l zmg@R5gl~&O0i2?Lkg-lU(#sUdcxJ`n>(S&}gmOcT7?HwV)ho=i;No>1vv@6wiM{zo z6#o~$y6^1b^oL=z=}7f0({bMsUp>lQ0_U4Y3m;?;JuE#AJVBFikTv=D`RF8h5-6cm z0M4Gc>+-Sm;Bg>`nS>;Fs2N_D`4#Ca1E2Nmv>{&k$EJyEZSFJj`)R>_>{gQ(;4|_&fs|7SONW%tM%DN$Z*W?u)$#2 zF4K?BkNrmiy_8;ujm})+mHYbZdsBGCULMl8jjNF!vX$Ok=Lehp^L~h`W5GXva=EaR zP_DG~l7N1@cqA+KC(%W2$Efz!6~3wi^qLr83#~I3pji9i;91*|IOKQus@|1zs4?p0 zr0m5jE^@+uLDE;B1M>61H@}6cgWn_LptXH7!5{`*%-VW**U7d6fQeQJndrPthltI$ zzP%)A#9f(XnxaZziD_Ht#Lm-hJ}FN-$w$Zi6^scqhDT;+N-t6|D6;(35uZ~Q5g||8N>AFQU7}j z0{t!iz8(J0+x>gj|9{Rah~2}Q_H+7wGWlbn=>q#7lmD;at`+$7{{@pj_Wxk=r{4h~ z)}`t=gh=TBX7bOpHT-z4T~=#Mn@uaGoO}cWlTQR$uCsSf|i-uXb|s zrVE8+$E!y&AdyHnn>M}lI*;4Uxo>^B?P=Av=dsf{LU(+^=e2eE-iOysmpr=jLu(P5 z%};rDUM$C3Qh^rNf4oCnD{QH}prD`?TFyP72dWZUl|aKJNlHfMRYMHZ2yhop{)GPX zm(U-lShP2HgX0+%5?0DZ2Gs6ZOrQ_yHgttr-?5dOmpyPV%l3|~DR3!DKo4ays8O3k zZ%hGnj#?L5Y?6b{A6_}@CQFy=)jKXTlIwN+sT9nor)M8c^**)%qIu$l3m1U5gYVNL z-&|FcxB64_^kiz&ix(ch$xTZMad;ClK9ET5HU6qqXAGSFsTd@Q7)d|`dE2hc!TP?l zj#p24KAqqHj$N5NXbp7dYkh1b;V94VHTSIoPmBl9@>B*+SkpOMya%2+^}QSN8CZTt ziVb+HC9^gt8^K}d{y_@fjVgp`kzE3|H=B#hpJ0ONB7e&?4pPf zU;wVmwT4LeZLO97>myGAnB>0 z=9i6etRFhH$CSj{a{tV)2{9O}j1~TY(uL2Acj&=eNa=+BvTBHHFfT5`tD(vXeuJq< zU?P}>ZuSzbaN+3cd$uc|C|?iJ@14hm5x}*kL`P>vJq!Qcw!Pc&Ry1Kdw+-vQV`%k} z5b$4n0136~(bodkb_U%udx*Qu!LQ58oDAIh7|?ZA%3r|5rewkS4@zsu29>N5e7qH2R%65hj=+*^yl%5QM_7-0-P2 zWn>e`Q{`nOURE!9@DY*dM6>nxd38TdOZtlittL>rk9u2kauYG!erd>#n<7c}9Ofm9 z`}LjfF4@R%FQ+xRt#lho_Fv2n>Q8ypA8KBHPE0S{YE_YbHxhP%lPpe7!?#NNfpTRl zyGC!GHSd?*#4TD**QJlVI7;d<@B|)!+0Q5Lm!v+iT~%ifc$h}tKb1060j!B$*jRQ@ z8(qEpHEF>({###%5GtS9wLHwT2{?xp4^|qy`9619*D)ruOdP{aj9TS}yV*wpF=iVm zJIb#8UgQYBoM9z|?B6aUT0F zK45J#yIz|a{kcMU{H{FLYaw-$#$xjYzGEmyb?9*(%gLDc$%2K+0-C@Ig~gs{mvw$pqYcF4{y^EoYL|7* zHbE5um0EtfyTx_23t#50tF_50(4QqXm0YzplXk$lBt5sgc;EBPbU}CY8k$2v5Jd@i zZW1XYe>W=$TgypFB~=}LGYnFFX-ZDD;}UlF`~@mlP)de<CM&+Jk{eR)-8q|7e-A9jE{n@%ij0Qnd0iQRN_bpJ)K>&d? z4gXXnsC;c6Qk9$NOi7KQ?>Q6FVQecmL4?VPDZ6Mla~KOEg3_FW9#t#|UdiO|D`D;v zh~dgutnLyOH_`jJ>Oj$j;(ii4s?m302l<=CHr<=kU-kam{e?VaE~~1edzQyLYH(Q9&kFH#Sn`_!+UNgIxD8|aQ0efi&4)ufQq`KF+b#Ez5*z5zDmJ+%#l zk<b|;XP<;4RE9(TJ{NHSUw(g~I2;Hx}4LOJXG_1atq8I6WTI8Gbe^*k#j9U&fl9lG~1gkPA1JlzcWfwBV-?bz6 zfKyB8e-~=BKzdO#j22m%`#@i1Fx19L1C`%B3@q{cuOFVC9t(}E(Era0ULv>*5eryz zc7@k|Htcv)Shs^=2BfcBfm2Bdc=1Q4BYYOPze)VLb@>(KQ;=_ye<iIAZ ziozb1Vj5@9pZ;${+V4R>QAb%vg%4kMAp$B^2~Z}u0%8ef;O+ILru_X{GWi2OSwd4> zTn`i(bL{jR-tA{kUVD2$kgjJ{J8p`%>r_OWMVN;9jm%Gvw4B1h#zQOPtF7*XA%aI?jz8WZoB@e zYNYP(b*VcrM|$F><2IaAx?J7ljp$!e1+t^*S$>6(h@`(S{V=97WsQ5`dvV7NKoBd7G$JSST@QV>_5>Z zN(LcoV z0YX~)q?s6-5$VnV zD6HJrM3cDPHuOL1(Z|jocZSMaw1PI&8ivE1%<$I(V zzVhJu0aCO0|9l1_CY+gcK#~vD?!14c2vhDqVg{#=ujXPdziGrDXBg%f!$VR;f$D)l z&eD5v)aM+!si0!^mNsU1CHH?ThJxp0LfPzn*#};JKs`VT90rtlPky&wlFQF*hO*2n*}%Z863VltfzEq$Bk z+u?=q2&4ZEUEU_lxX<`ZFLo(F4JF$_%oXRjQgpD$TKMk_v|d8#c>Va@8DqBoY6!SF zlzLY9F${klM!44~CmHS$uhd?0AC2*slA+WevqOw>B#<(87kNGsq$=Xj2_{fndtOa@j7E(A4ucC8GmIx&#{0B8 zyj4YX>WNCGY*4R1m^4EBzekFZi>zd9L>gZx9Z|&5=^_p$4gMd0DyxD)3vMMM2>hYJ`?Nv7+A$9NMf&!_xG*79f?*Bc3GNuVbdbGR6Q}G%b z8Ma^R&!HA+B_*XHm9Rbkn+F$=#||7nGrp7{ef7`DB2P;QT)O`E9{MC?gaX5wulOM1 zK?-Q8Y0+|1HXQ!niXjk)8_*8X+J3nSMmlq;2Skc$NLV#&^WQ;A<|Jc~2#3>;)Ic1a zMfS>-E5Me$VbiYvUBQGvlq3df2R^mwWG4fCj@FN#*Z$tygcyrgoecFrSy}-`^sUZ_ z&XrevlKQ(o5C)Ic@7_IjiepB9uAJK09^AfM;py0a z-VX0 zTeXQ?6sZdx{IsuaY2VO3ATN{KednZ^5oM0im z-x#~}1?&sLh|?nUOq^CcfO4MRo3I zoL)&Yn%w6TQNl8?(3yfK5D77&WF~~p z6ftZ~RST}|o>jj7=2-ue$84h7_SL-a2?R_1d;Z#X z_no)QSV&AGMoi6M9etjbWvJt;H4h#?*#^s;r^j{uQ4X`NX!XgMKhT9RFY@ zcXsuopxUv#(V4l(^qG%jIeai4=yi){Glo|1d!-B6h1;2*;s(D*K773sJ5yQh)93JP zedWZ3FF<#5ZMv_HRT1mZ)34|GW>AJI8~%D|=$>fM5@ZyTA=D|Zi+Iw`Erh}INW^?9 zJy>;wE|55h4!%*&y&%N@C|bn*>g53mOaOtpV}IS5lfWf-nU~Uxf;+CO7vbbecHyc0 z@rK`J!)1R%vf)l$xWQyq&2D6#PUl$?&9u~eO?iE7+XtKtqcW$J3)_st=!Srg+?Lp- z@Pcn^FgtP_=z5!&rRL78GXD&6mDGl6{g=a6O47c>)!DE6(tj#QTyFbh(HV(Qi$ zyk5=QN6Vn@QAoylKmu!aqA2%H_Y9xkMdH%4e0(SuN%0Nv{}!Op^^mdq2ga-fpAsl9 zIPN}%6>T=iu}Re+VzF-E=LVbmKGrH0_D)ePx$D1Vt^vaxJ+eN2oz5>l84${#_D$^~ z4*@eCwsA2&R#**TCvZ8o?pUj(@+&Dzg}dW79Dn?I;{iw4H~u<_b( zsE6i$`^`)0u}}ZXUs7pr=iY_wp%Pv()8=w%t)NtYTU|4{C_blSW`6sjR2_`t56&mT z8R?}T#hTRHw17*z`(mm%)E{RR_uW`R07R^9D<6(nn{ zJwyt`DHX0bOhR~5Thnc)skg)-zOutYwb>^F37YpJyvt+qo`K}XelSR5VXr+e5s3Xb zOblJ^hMoX8&T`%c$H$IXZ(LMKI^uE4i=VPBQ|K1PWl*-f{N?4Z_w6fgI)Z$u|6*Uz z-Rwn0gNDtX;$(P5ecDosZmK8h7iVNW8r2lJTMscJ>(rLp9z8o^o<2QjLVS(SDvrP} z(#f%=pDsFAi=D~*^7`QmBUC}m4V%4?AfTfEJtg{fOhU@VMCtn5TQS}{MGrteciVZw z5%H*UZVH3QT}P$I!`va``SG@w*UKNKefLj7>QZ#Uu_v=_3iSMKgBfS2S>MpB>(SEW z6K;;6EfDdFRiIQ+dna>^B~o$}cyxX={%ieI<*Q(su^l5b2M8my#d?|(h|S-4=0;pC zJIZ>J2zlFc&JRly{TAIz_^p5SLok2bRIGEcGMN-XaG9Xm?A4K`tCr5no^l}Ssx3Q- z5IRstKEG_t34C%BQ@|m*G%OnsX`MVf;V@F#8j1xv+zgoI{q%TDFVuDO`N{|r0d_JY z`CXs!pa}Lnwb?NePLh#|7SXGa`)pm7wQaW|?F^bu1?gEcfB(u*>h_0=O-jLp@fVDm z)KhP@#){WiTsUvW)60*vNmiVU4yj2!P>wQ zhxp53^F8H5K&%59?0~nynis)fR`9h5_~kTohESAAIk6BW%O;rtzy57FzoH9F6+LpD{8x}gWce6x@YYt>oob^qn4xKN%8 z1{FBa_dtF-zxTIQbB%d?JlQEO^x`6lI)S4m7$F1)FHI;Mw}kGta)rM19=7Wd0$Xy!7p{Hw;d|+xC;MDb>Rg2WZ)$$SFN#P| zulNDy5RYSaN19fylKH87BUi4nj5gUlSCq=*fs5^!XY>F<$e@hOfM*aCedtS-Q;KY5 ze?bE^D;tl*=QOL;Pg~x->Y{S40(*Q={V@eQ50_m}fRe9GnV>i4cDxa6+Q`E%J~kDi z39RRt#p{Q;;Ig7o9@Wc|NIH6c!sKZv2&`_prA?PCuD&GvFwO9F^p=adCwzB`Vrrh9 zg|vH;_U!o&HXf(8!1)xi z4Bq`LU3PZ);brzOE6!sJov7n+kYXzPjU~G0Vb~^FY_WI+$p8GG(*`*rlY!|?K8|Cg z{nBq=oiPFI%=qhE-NVT{_+JrjsCnHy+g+<9_EnO~PR}I?D!4T6=eg&8G;SZ%OgI@7 zMJKmlWFr;j&O++;Hq3c{&g;0({97EP=yH!2r(3eK%cj>&tQtAVuJ{8>a2_9l?Imwt z4oWsDVLG-g4^DJjdK@&EErPtjK@l;oJD0bYL(!6L>OuL1DT5VY1)raVta*vrgB_2! z+C(uC5bYOH=97eHo?N|D+TzEb5It<%z=eMMskS`KlX7&6Gu|KScLtSx%X>e zYs(-7&#Kh(4GpMJ)8azvHH zzN5j2D{pCEPQ;2=n)?1AM*C-R9*!_nHm_;|GrFagsSNt7Ory;MVRL)Dzy~$`S|_1! zChE!P^%KKTe$fbZS2*BO&OJbAdX`)4D=}-}DoK@%oVs;6Yoa!z+xhGspW2DdTER2K zw0frOI5~_|Yr{fIhSQmAvxHzj!Lqxh!0a3bNG6@r%t{d%b4uBL~&Qw?DYi zByU$*Jd1bPfv?~aPV(vWaDvmHJVdd>*D}8sUJT3^eyTccWwXZ z>f2YlzwhL^QiTMc%kFsMNhLcbD4lkWFBm9jt1?1Zeqa*@2InRh=N6J^t|XW*t(${P z%i`pnkp_NAz%2^rb4KnRZQ3-wgtLR@TfdfWYOh@+@`c$2@8ZDtn@J4>av=jJaLDjn z#9gR!P6oyH@$M78)!H#?<-LdQs1!&xN-<7bPj;2u;=a*!XTSOHLRXK*;(fPlRRzDt zRoj#BjE&{7GDraj$Gq!Cc5l(xu9qf;i4$sq7`xN;GKn7YE_=&AACjByEFV~ga&ZA{ zzRxk4P?Ysw+wCJW>v%SQ3qL30=7*Qo;he`$pSrDM!G@lRJd?44{Bp-CobO91UKx6i~kX1BcxFMJZtGYJ3@ zznSvL#7nii!lLTKcSyqcgadrs=hKioa+7ZP;UPgjPXh$PEAy>SkHqS)0jK>ilz8`Z z(X!0nbfq<~#?%c$M#coPy`>hL5}bHc;^GEGi|!|Edg#Dxh9ljbR<;r7jib8#g7oAG zv7a1`eru8%dS+%97alXe7Uo(v`7YoC^_c^v(NJ`sXuq&s%UoYSns|`YO`Os=n13Od z_c_95VU_c)2J&&;3mjIW!C89yI10-ZN`ztyi4r2WRU2K=(3(|0{Q0tkSK{ z#~C-TvWGL@e+yX!+plt8dIv>zo=F9XnKn4Uyt<4u0!wwTi=uDwq$%Xred>F4cN?7B zTxX?sMevl^o?7I1eA0${m)nM6TLMcHZx@6=IIYNJ8N-W5f~m;?xL0^^^sT}5l8}Be zq{>BkqU)au3eR)RuW!}Qz1>MStA#6Xx~>i`wOqqRLKNo#^!kIxYIYY ze6=%0NfQb|bVZ#K@(jE0UIzmXvW)$6C%NhMivEGvBf6G*SHYh;O3lSdHnoF9vt3Cv z4J<@#O`4Vl`_C=>&cXTDN86TVBlNNLTrTo1mt%_wE^kznl)?Gh=S(hsCfw0L$3iqP z@D~$R`q!Acn0MvyiOM(O>1D!br1hl@g0lQT>Q{^;DG{cL=J zLgtAkUb5T>fIBiEdnLqk*M;SP(@0<;Ry1PCb%eWnzvca5x}K=^e3#9g&}Lk)p_cHx zDkD*NZQNXe(=|al63UBnBVj&Sc=KDzPEk%>0?R)b_idXbU5*!vT-^t%JcUnmkD7eX zNd?7Y+X_xdMCS2RbV~E@UJxaBoG#H|kNzg+^Wu=t;}&3^IAKwY5Dt)~zt3 z)6jdZt%`0?Yg0P>ZS_|S!lgSJ38`29S^6bTN6RSwX1mFZo0Q!eTZ0u zEfvOUsb6O#T|TyiffaPp2c(h`qECHoVvhQqyN_mc`bJTsn>-cjS80$?2?-T-QV>%+ zmh2d@JYvCFaDs_Jil3cKrhJQVsM#Z=c8o|@9GI3!U-bmV-Bj2(8_s9C}1<~WVidmbQb-F`Kk3~;?Km5oin`Hsy%92l2 z+PL^KQ;4yym+Z-4VS~@A?m0o1Xeodb%}(i3*8_9x#!HN(yrub_T1v?PL*ewkd%W{{ zWc+Y}=~dgek#A``4VJP}5Zm(~^)%-fxFcZ$>D@~wf#ZK@^3fYI=}@T7*qKh$dMz<6 z*X8L9wIL>vb#=EV*cl6;0H?G@Dj53=CpO<9L-y!kx#5Gvk(-y4xbX~)?_1n7;J`bx z7+LsHQj;z-BR{dXkb`a66MW@L5Fr&r=YAcqa%)5;78a=hOi_^K!s>4QdKOSaugMh3 zSqXd0F@%40_->WtjujBi&VWmJNgpU^44Zu^BR{k$h9)6>^v5ZC@!4G3s`W=9_dJOg|(OYX_7wzk%t(l7ht^IR{j|F1@h_c!D z*>WLGrh7O65(AF_k(Fsg-n_;YE1-s=^X?65rObo)@bpzsl=qT#>X!f=WK!Q7m8Gq| zcG_PxumplZqpSc{J)N$HGN2uUIXSg9@Yw=K>3_D@cNz393LwAg53X@hz31mkkWIdO zdru(k#>aq$mzRb$Td&gK#h7FaD4s@?9}thVdqiOtcUu(|VK{k165q1k4gs~LJ0p>T z7dBSN_RP+M*!Rufx^8aT9RQ<2!59#(oOt5#VOu)G1|f9|7q*HXV>nC0!*fbr*t3AF zuyLA<_IoDAwW{EsZX?YjhOhH1-y7IAVO=twEDl5-=DD!Se+wAP=~mG1U>V>U{OdD2$C-QTp9%MtM~a-%aD4*p`iwR$C~ajPRPQ|JA&{vwRa z;oPD}0ztI$)vb?*ul!oHI2*OZP(yqCGs#P*DCx5o)j-jvRyZe?iv7tS;S8{ zJQbCj$Vj3(ab}^K-z}B8JT~h)x{?7Q;MBnfM|vD`bD0r#qMy z`h^u3xcY88nUNIuL_a_cHnd#p3;x62fj5+Ckr0BM#%R=v^X{_3T9G0Bn+n#5QQkON zCZ360f%F#0w6Q`ARX1SE+w=3YLA|Hq-v!W+7QpS~Bexi(e$uDl$hJKY3-S#&l~)iM zk*Hqi`>Ba=LA3Debf~$CZ~w!ShbFr3A6_}T`I%QV>zyt-G-fg{5oehTlx%OAUXS=z3Ea_ zJbA||Ke{*Y{hn+68uPi3g=bO6G>QnZGS`?Q_sO0LcU7-WDUG|^@7)vfsnOfB(Z9}R zs8-1>$_p7Jv`yh$U%Fq|3nhy;KCO|r<1vVT3p?2=u;@%8Nts%d8zIlcA=+iBUXRx& z5_xdThC4Io;EB)G{qTIjz|I$L-v;~nah#X^4x8s^hys2vtg3*mV81=zJqpBSU)UUH)gC2WH%d%Y!&^xX24jnt9#x?i2<;04S3*a;nTvd5m!GZI8m zXLgV8ole+#gz>?_Wl+1b_|8F*jv5r2RYQ&4Mb{i`p9G%*T`0f8MGG2}0vcb}c*_4K zS#0N!xERUW`pPEkS)z2IezGWd`uJlg_Ws`YhlcSqsHW^#(|LcGnA-p&Ab^gA8VgG3 zV)rb5{~`m}0u=){-=?YeMM<{z96x<7yYe(&FsuyB?+@HwX#s$<@G3d>yboA9s5QDC zG~H{;LC)>iqRU^1E!QBwd5=uQ`)`}`Z=XN9>_^x?=Y?YL?4)%ID1yBBK>6coI=7{B_tHAtB>!W<{sZjMU&u5p+H^B3y;lfXwU7+_kV*( z4qm*>eY+1w!k(ra&{VW>mwU?3F8abUdxwf#@?vrLP8>TLu=*V&QMz^?P_4&QX&zFZ z0oLgW!b_hIW6#INGr2$n1DdH-Rj#Hg2dKwB$}MxoGm>^uCsy_gAV`;_!f`Yq@Alfi z0Z|!bkA|(fCE%4u%h!H^N}hYExbWfUqa+Zx{Cb@17gz)f+tm1|w-Md6v?<(wy<{ir zEXi_%ktAE&S!3iPNOwF`LBxCLNRW+-1@6JgFX?wNy|IEWPEdV`{qdrNYqcA+8>^R- z_~)4p%D^Oi|7^%XO3rrq`B;UW9EQ|An|6EegMdyThUdxveQgI8yk}FS?f#{!iq4e@ zYx{r;@D=2@!jzQ)NXglQZ0LRfqaev4D3CMz+0b&d9% zWCjhz40Ns{Xx^8nxi5crd}$J{*rw>RnpbD+UCb}B$`CUX9C5F~s4!ZLq}L`P88S)` zi7S{v9q2q(fa7#FNX9}V`u4}+*-M=edSe?dxd!WzBcBl$y8!0IBmScMlLEDke3elt>onY`qF`7E4`ClDeFgDor$bOZDfHHu7s4J zmOu>T_0z!W3d%k`E1>~~z$vIkkfKu<(QSaE_CK`^Wws)l&l9Vtm`^179_uYMNL#1dxi@&m!@cERG+SXD3_ev`50Y zm&ks>VFuTYKc5Ok(SmWyx?9=FtJ~O~Fe=iXuh5j~Al8!e?#(FnamE`jp|8jV^+YUQ zB9TUM%AUPP(^45pb`s~-l13uIWZ9NzvGeH2pE|YZ`}>8uyo+{^4K|Yz4vctjQ2l*B zH}0%8i{ahk07~3)hc|$g8b-~jk6cj}q&lsig@xD{TsE~7Ks|AZqU6k(pXN$DWQ-rg zLf>$h3LU?&uqV_VO>Uy#+K$d2+kI8%__|o)Z6*peO3?bh?=J-T%ajenHf z*BVpjDupTs?+Gv>T;fduVePH}>{6erdmdDB+S_l>F_)`Y!7-s}SQH>LLKI>r3sW*@ z)JuKfKjphHphwt;`9*KMB-s!i3&_xS!udMM4;&x+AD2^g57Z`qJC&w}v>B)N&s(n3 zK>a4bKO(r!dq1}=@EO`#Kih#?w>FTUC9zt9Nhy2TXow@`MK!-4HAABG zq_T;>zyH0KxV7Dmm%ug7G=zP8a4k(~ON6(5gu8h@#>IT^jSrT+^P)PTT`Q>+6Gm9t zr4_7RUMREM)65Nz+!)AP;uMhggjdyPW)8$g28ir6>6%xa64HK9j1(vuQay10?kx;; zyDP_}F(Q6k|B{Sa8(I)49-n(!l02dLB?FtTc+h$a!|+E+ZQ|SBk$g<@PO&YzFZ%o- zY2>n;xFwFC?@;LHCubU2%ONP5!w2niYeD5Y0-c5h&uk5G6#m!KIe1;wDGEczjV*Da z=+BqkrgzH8nU0hw?+p!-YPO~TIL}WmZ>MJX-8e?FZ*kvFE^HF#=@T}C*>QWG0%Cd) z(*xZx(3_JF@rRb zlJYtG*W#3Z)uQ{I(s3|ojHIG>7OG#9sZ~C(dsaV60_yoTChrUN^=?r?6oNXxyW^jW zFWrYTs`kQYiT0DK8Q!f5?MxWhRHetk@Gcj>SiSGIo49WIX3`iI?R*AHWyAEL^R?9_ z9XJ;o-y7X__!6IiXMNfYPN@PaNDVpSCwK2xLt*5pQ8`XFCS0Du1#Ta^yWvi+RSU!U z4{jmy4nVfG>?pZc3N)o3T#xcf1oziI$(02ieB@pAfNPPWkFDkQV?#IQ_PfXtwXdpg z>o^@YN)PL^T92*lnvOl$hQw%;Q@<($dQKJ` zAB6MsUk8`?xI*rM=yy2k#)BWfF=9NWAved=Tl}_lhYRA}>YOEtVnUM`$$Hrg`&^L= z3+16c2l=FqkKNhqGz4{qZRURbx3@q5uZqSoeq482_P;|j0_9&m?B@FX5YNeW>Xa*3 z1!fAOD+l%XAh1`dpU`QMf={im$c1EUpT$wHLy6qs5uS4mgG2osP;X2le`iF66R@v$ z?5N7Pbf9bM@v&tkHl$eHpnc;c10%u`C!8OYjlDUxRU0jZZdwA$*eP*X)ULtjGsN*5YYc^{5qFH+U4$c|!GSktRd%8|3DA!LCya}O`fL4FfN zsnNnP@oj?)w(_mq&f5l@d5Q3V?f~o#3HY2KCvSnF*m$1EXQGtmz)V_2HdHoSlGXVP z_ybrd%{dxHZ7Q9-@H$mg?%iSgP23qiy<&#!pmkUJ`$je7(>!Md3hLX19 zD0|qSKPxG#D`5=ywcyXSmu@2cS|$ltu=y6VRQ z$7JbCC+uY=DrCB+?Yyl{sxy%UyY@`n>_jHUuFb4MRp_QC)j`pwkc}N}J6BR|?Z!gy zY54e%1||NF5Br+Vy+_K$-Ff(v>ZlxlB}jmg-Tsw991~(u-oE`qWjgKtV`%{#E(j=tB)R*}{3SH)o<)B1WSiZ)@0hOUaDrz^3Y!xnIey!C#t+B9ksEQlO7n6jkB zk>BW%A(b1zC%AgR6EeJk)7grZ4KI#WVE}r-5Q-`jnAh)$VQ#P^?+X@Nvm@M9W#|)b zU{tmE^Oimv`OP(P@1s0j*VYw0CxgB3X zU+&KLrY=ZafF0zbxe$&?c%#MQ+-6$KR0Y-nNUE#HRSjM2m3D(hPq%W;3+{dl% zr%<(u4BTzbNP)iS*@B*_3!!m&SV8RetcEak?`C>S+7kIS8xCND<{Z5#x(^NH;DLH0)#HnEevE5?^MD=D6@tbRo&@X;&DjHJ!ND>U~LSGbBi|D+rlj}Y^A$F|pUk@F$sd*qz2{fP_O z?nE~38Hnku>cb&io_&Rz8d4Lv#cST>1PF-Bcpmz8%+FX?pr7WSdmP6x<&PKN9IGp{ zq1&vXUs;=Wt2vnoq6yhwk(lnUgU{pV`MY*Ar?7K!l!F)3M)V28$|cM3ggqilg)jv*qEE4M#nxx)+6E~SqCIKA`X6cHiI7*KwTRrum$6X5xivlJJ#dQx{+cJO+ zeW}8v%h?!}%aBj{*!_71{pBx5kc*i=j4vBf^Xr0rU-~|4{^7X`-|y5Y$AT)RMaHeW zwg<1>zuh^7}O8ueug| zwob+*o1-^VC(jy{Tw4oB@9H@b21y0ar@mi=z;Wl1V)#lo+0H)}+_XO*Mc%zN`(U(h z2enw$dV|Z}%6I9RZzGO`qdsy(-~!&n3)p$6m?$fl%tc43O^O8|z*K?1ie zWGw$(Pl0Sb7nupr>{vT0MHHD}kC>l@fRVmO_6Z;epR6}w*m?T)pE{NC;ie&L#i@bQ z2lOgcL4**=+Lz~jo1e57$09E@-m@*`r2&qpYWZ210Y?M?t~9>WsF;lRg7n$`dmqWl za}V}$m%l{dV8;-*GPC*f&C%EV{q`EA&9{;<2jY$=@aQau;^qMczLVv@yoM z_gi7BFP6^7aBi=p)}uH&0D{WEY>#b1L{8Xz6p@Dap3vct&CZAtvH>Ej%Bf-Vf`EL-`L88xel)S2XI@{lXWFaB=%@vimz~6q z@1M_FT38$*x(JdA@JIFY?2WnW$2MaQv5}$r=f6JiCW^<3p0L2?4!j2g!JP%s?(ie| zq532e1uA)}8z@UTY{t0cT#45VLMR;YGl8`DysPlky%+4GV{U6cmk0(5Tzw4QC+6_1U8R4?Nriq1PISvB1zPY)j1qry!wBDp4EGPikd z=%%%3?h5=Z$d39#rC36e1BA^nd$TmTUOZ|HIri;i*);IO2-?F1GKZPGb0iO8)di5ru7_?f zSUkp$utxa`(gT`(eQ5{nYlLj9t?`HaKRYK!R6`PScCKCbOJ`o!7KFA|(YXLFM}Ox? zCiUo`nvdYfbziiI$83-sumrQD(bb2At z|E}wIUHiRyJ00igdG2A|Ypr_?pHIj6yRqbMyy7SmpM3u5yvg~Fpvr_NF?GR6wJy&Phcrk+B3M+3CpGzVh1^J-b$1w2DChQ+(|Gf%x{Udi&cuxnWG)Hdbq;WU^9HEspX9v^WD;f38C%W-?>;VDZUSy&%Ue(A1sd~C@`>il# z65F<@l3L<;iljEaJzZ?bT$aJeYZT(@hD~V;s+Z9!MY+(sdsENi7uNLfBSBfsg`WEd zt^0(k*D?J0NEn99PX^3@@Eq0IqXv*cY2849JkW$4XVq4$6127uqxvEBc?kjr;dfnj z6vL&$v`q9%HxR}O#J}q+6hKqgLhaIt0XCN!U?l3Z_F+S2wE}5fk{hb_1?ZTIiY?a05L`Q?&mbiJ;B-p@L zHB#@FTQe{K-7Z&?@B@KfB{3AuV?LM^9>tEu{B2(x5y#nm{Lm>%z57Gjd3C@ITk^7Kk6 zG(1fD$ZUg^km2A?Zr9~naM5ES!)5E7g|!8g@0DDs%~o1fgGSx;JFj|b?n0|;M&1a# zy!8m;sIH#87PypF_qArsI3zFk`)U^RYPDXV6X$7L3cqf6--Q93shpGr-W!<1FH@>t zg}l||Y(`3?Rb*nHOMGB?zyF{(W_t=#fdo|Q_ALabXsVj;K7t&?9GI)x$y(&vx(;00 z)Zwkqix<#Kx%nUV~mVLdvJIo3o*Pbp-7B6ThuBa$& z?3cJtFIKC&WyDQ9mf5fq?eUOv{(Sb;)_eKPy^P*3#*whgj^^JXH+!x+c>mB#!UqzL zh&8<-63W~R-F|el<;NBH_ISm$+_sC+z#J1L$QO&!lc4GQx?5+Ne4EggQ)A^nqJbnk zu{yZA=jUQR1P@F`yBY67fTH}aIAuCC1A#R-sF?E&SKUAT6rr>;oxv0Z&Y$L42zL;d zJ&kINi6CS0Tj&R9IOO577|^=ij`0!GVMWJ!hKiV1PZ!YgjZ2(8y)HT$JX}<0F*=h< z)xLb&`T7QA@+)84wNTX<4V}aEt6YFwd~)c`nQC&uMfn|JIi(Qz6&zEx+`4spb+D=A z>jUhV>C^S|f*6mKX8-wb#fDvP%hMTnp$uxf)*~f;Mfbhh;@(((!@(^7f(Kxrf8tnY zyudlQ2i=DBRDmWtw+p5?%GR{BcXSE}mp|GUm}WMVy%W_VWsVYU8qAD+;Sjq5n&j10 zc#e;Y(SoWMwmHoTu$&7@rYBz(=DXNlVvYi}KsDdmw8FC!?FXJ+cz#j6t3Gm%w0~Q- zu~-uRJ$1`I>_$kE?kaK&`Jnl6U#Rx(CW!vvJ8`<1qU*7-Jxq?>8o-R!7J0;f-a zKhK>{KuiFO?0)Df`7Dx8-Jt7hSQsD9n<{0Q^>WmylJ6wk3k%Kjv5{yTHtnS;T>EyvXG&hs6QnF|LFD z)#Y-h85bZ@d&~r?Gf4tTe7RG6OWJKM{oAX0TjH@_sNlQZ;WAHM<88d_#Luc=YS5<-)F|7p zeqd+|mUbNNfnG%kh-E*_?{`+`!#E`G^^@VoWnMft)~Amc_tNn>#MW8Q1(ieJzy5&U zSHTwt6yS97PU~=eE?=7a;yT*+WZ*ZPdM z3V&~>&Ox-C_iI;0dioN-%I9+KM0&FKv4;f$z8E#P4rvd%4|}1JNj7k9@-iP~I)p=i zUTM26z%V?_v&DxW^{xl!tff-1M?&}jk{$h(-Z4$Q_;DnusxgL2Mg%kM4Ud1pSH;mlb)34E#kUZj8|%jf{@ z3$1H7m=ytG0NM^$-N)JMaGiA1?x2pR?Jui;5c=SmgmIMBte)b#U8V>ef}Of-Jxj*x znyMDRxrauEy`nq6r^p1kkNcp_bka?$g9`Ob#s$=Da7NJDvRZoifsXYHYxL06L-=0P zN@fK_o^9Jr3)an%2i$7|e|Fw{MCE%b!UuTu| zp25;0h^w3AQyD$1DbGGZh2tfNZ7tGGTRnjk>x<*XowZ-dBFbuaUBQ~$K7g|N+Ve31 zqAAqDvf$pb*Pf6k_la(g;_mr8zWB5Ib_W6(qRd2~xKE~VkgaPButf4*v~Xs)B`?96 z1g!GcLjyR+8w{|!(2Jp2&1WiS&m?3M>&s4kk-b55O)h2-@gY4kQruM3YA9CmT@j&Y z6Ut4xaHGnF!i-;91g#WWE-Jew_7cKM&Nq@>67^<;dtltnEWa0XWC~E%n-;ruZsc+N zK346b8A^ppUTHgr6K8)UaW*pZ%EqGArc>@l?sO7sSjmnhNBwu;@fNAdo7P%p<==@4 zBAk2p7DZ_(>pg>Yyqk=?TiWOKW|OOZ{VFEt*0JmT3DLDJb53SA>4;LSWF+THfEgYy zj$Kj+o{gNE#{;m`CbyDL^zzItz7#*%)i=nFtJsA#TY6PWck7HUfKPc;e%_Ps=#cAG z3`d##tKc&ko!GnUDAN<;Pub1E5D0J_875IW_1-QW(5z1kZRtvxA1tqu(c#U=fJC-a z;Z*90HWk0gBdMxKlir>5lM|Y#)SGU1a=l5S$NO#FT{!oqdpu%s>1UOl05TOX@9(0x zX9||A0UYa+kNc)0X58pOTPjm}8mOG4a}OAF%T|Jx6Y=5%nI?BCD zlfGamCb$m%Q0;?-t1-Iq(0bfF<-R3`ZoM?D-0ac7(|z>6+H9r@gInz_3eTQd&(kb6 zPOqp=ZhJ75E9y-xNnd&@xX@4TBa= zL3&%|C(dw5<}=b`f7YhQmQ=5$b;tL0Cw05so*smXpEq7}ya+n!FvFci;S z5X)4)dZgXd_!hkx$ST zxOLR3QMrx>E(tHTksnk=Ewoh>PsWdRJcw-HH_3JW?T{(53kz=*V-?&Gdt+<*i1ZZ& z!-e?<~v zwMWkH3euL31w)XB_5pTtwh}6(v$rMUuS3-Ut7$CZb|#B z3lFLyTK9{E9AWfmF*1KS(aU)d%EUJa0A@w&8u0ujsdH^rEnY}6eVX2?o;fPimC;of zv@_ZNyyySZtrYwS{5k1MRihQx3j?AsO~vjHF0t@sfMVYadF*R|@bcls@Iyzm%^7MT zW+$61TPF#U1<0Nk|`vo?> zE&Q1J_#FV$cGQJP!8B@w(!92lNz>> zF(^I(T+6dxY$LT*@$NbBfCFtisB}DACp-qaDRKU>NsLG}6L-)1>dR2s{;L<{-XUfk z3}$-MgPXNMeCl03SGB*_aDQmL?%rOcUjW5Y@2i6K>UKU5e${&)o!4&J02Lc7q{h|} zA;M?>;0Wh%n}N53y@4F69RuLxjbK^jW&OA*8+aeh1%eNE9r9E5%*@yIpi&6MNpg%~ z&K2J%IW4%&)PDux#Vi!1WCsP zJFpP95$z#Wd2)2>rAlM2HZ^Ld%~Ly77E%QX{+1H&KP5R-_&v1G3VZlmEg4FS*7al8 zTB*s-ad@2etl@1NlImpEUWFPtw_xLiFI~F_p4R~=(cG5Z=QDFC(DQGWts9uX_eu-& z{^(~RHFyfavF7ynQ(jK0$4z!>gG=IkB7+AcT+Kb&U zLXapjRH@(U_ki2N^H0)RT}z!~K{p4IUGVtsZmT6zr$FfJVjNvMWtR&rI2X|}u#=NR z*2W7AdI1LOp~9r;^l?SjGwED>b`BH|1{TR9o@fu!XT_uMWO?{=AAe>%x=*8Faj)O7 zKdn@pb8oe=x4gFIB z<*kY!<_*`&?(glg3||I0C&T;GH2LzUM74d2j(vD=o|CHSZEJcEOhJ5f%N+r$lUAou z7&;Kjef^A0TaESl^`r6p20d|Lu%|PA{#a1UVs;DenC

%4#9S_ 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

;3K(dx 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

Dk;b0;l!;JfkgZNIhk0z|5^d-bco6q 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 X%WEaV19C1R00000NkvXXu0mjf?EToV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f00b11fe00b9efea84671b7976346cec35fae25f GIT binary patch literal 84522 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
D~twvA&_7KYm@vDX6pSfCgMy8P*7K@I-G+(C_ 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 + + + diff --git a/web/public/fonts/JetBrainsMono-Bold.woff2 b/web/public/fonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3a4e333fc9316f9989e16af6788bf1fc788c7084 GIT binary patch literal 21908 zcmV)HK)t_rPew8T0RR9109BL#5C8xG0O5oH097vl0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/web/public/fonts/JetBrainsMono-Regular.woff2 b/web/public/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..585887339efd032b6b715466ae72d86e306bec79 GIT binary patch literal 21168 zcmV)FK)=6tPew8T0RR9108+335C8xG0O80008&f<0RR9100000000000000000000 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 T=ND-DucNQ`tADQ-AuI&|D5jKI literal 0 HcmV?d00001 diff --git a/web/public/fonts/VT323-Regular.woff2 b/web/public/fonts/VT323-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fd760b5bad0f5b31d4b47b8e3bf333b16f4a69eb GIT binary patch literal 17936 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 literal 0 HcmV?d00001 diff --git a/web/public/mascot/egg-crack.png b/web/public/mascot/egg-crack.png new file mode 100644 index 0000000000000000000000000000000000000000..14d8496e0151a5ff151d1c4cdf9b92457d2c0f9b GIT binary patch literal 167 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`d7dtgAr*6y6Amz4*%JFN`S6V^ zNmqaD_2KOKZ|l#JEFr(%@rKEwmk!E6RI z>5H)~SAVwr>FYCODoo&2xGJ!SX<`apKb literal 0 HcmV?d00001 diff --git a/web/public/mascot/egg-idle.png b/web/public/mascot/egg-idle.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc40a984f63e431d21ac18efa1dd3a5adffbccf GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`-kvUwAr*6y6Amz6*%JG&*}%@U z!t`JDJdR^3k}YclWra;x=BF*gzVN&>Hft8th!+r5THewS4$Dk;tzBwdQm ewPY$R5oD-edg_(dfw~T$u?(KBelF{r5}E*a6)9{0 literal 0 HcmV?d00001 diff --git a/web/public/mascot/egg-shell.png b/web/public/mascot/egg-shell.png new file mode 100644 index 0000000000000000000000000000000000000000..2280e55269f88432ce478458b8b770884209839c GIT binary patch literal 112 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`=AJH&Ar*6y6Be*d5D5J1KT%Gg zUB<^&Jb*Q!SW3|@PF?WP;}XVQY%wMm^%PDrbR9}aILP6DieV>H2;+R$HO)XH7(8A5 KT-G@yGywqXe;+>p literal 0 HcmV?d00001 diff --git a/web/public/mascot/hurt.png b/web/public/mascot/hurt.png new file mode 100644 index 0000000000000000000000000000000000000000..8aeb70ee64f8e99d4b7e9d410fc4582ceba4d66b GIT binary patch literal 416 zcmV;R0bl-!P)Px$T1iAfR9J=8m%WVwF%X5{o(M@@z$IG%DZw>C2j~jelmbX80MP*r5MFR06mZEF z;FR1X7#TA&p26lMo`eM6{oed&c5Q&NLh@m?+y?5piWNLeJR{MgD}dO~X19gK>7?OQ z>~b5z?z`iBM83e9@@Et9-m}imxu$GZP)%%e4j)z+0It_>BbAJ8$iA&uH*9XosgN$3aE)4d9z1sK=9t9G&)l73bq;lj7RYih^FWD z9OKaAyq>GgYuFaN#$%HHaL%z^JpN4abvT$4h?=f`pJuhZlt1TjHvtl?DPC$`+Ieax zcqLDHM5(u*T-kp7wtSp`PVttN^tODHb3mFzo(Q*w@@ej0lqV5@`)4U8PppamBgUb} z=jJcHk6L@+D&x1xA7LE&{9r+?i5|qgTD~@Zi~Oq!XrbEpD*gfh&nZ24#_lix0000< KMNUMnLSTYwxW01$ literal 0 HcmV?d00001 diff --git a/web/public/mascot/idle.png b/web/public/mascot/idle.png new file mode 100644 index 0000000000000000000000000000000000000000..492214f801702918e6804ca3d7117b9db7e58228 GIT binary patch literal 368 zcmV-$0gwKPP)Px$DoI2^R9J=GRXZ1kL`q6&%`AkV znBWw^)hF|@Bu+BX1g8M5{wn^-8@V1{1Mlz38MG6o4N!oXdUtB^yaBlS3qFOb8=$0= zKpCAi2B;x_)$**mx+++*#o)($;Lap4_y6V zeyj=jbpwN8-lP=3e3W%z$QpVJJ2?NFXt-wPxsI*Px$2}wjjR9J=8mN5>4AQXllO-I*v1rOlr(9sL@BCb7vr|AV89Xi%4c!0OM(?yIT zAO%5ui3tI|pFe-h0|w!XFUBl2-bT>X00#hg7sWjB?EAmZ4b7W`d<<|R(k>$6?%A<; zorsSCBWs}%mA|ybcM-^5ALU*|)Q0%&GI+GD@vJj=(M35C83nqkS_w#9*sAl&W>er-P0%E= zm;P1js9S*Tbm(Mx5rD6OBZ?v7=BClj!S)g4%LBl46%x_>8RkOpv(8ZG@;=pEDE@o{ hpnjLcPx$I7vi7R9J=8RxuKSFc4g+4(gTqBIgqVnuhku;UIJK>sZ=0~Fo6glx`xgyhYknd`wZHuYsxMA> zHQeS)Av*+xSR3GCe((mV{lkj{1Mt2hD;%l);w7roB@8@V_u+pvR*Q0jy&S3iGT#>x c)c%_P2Cjw++vZBE8~^|S07*qoM6N<$f-;Yv7ytkO literal 0 HcmV?d00001 diff --git a/web/public/mascot/peep.png b/web/public/mascot/peep.png new file mode 100644 index 0000000000000000000000000000000000000000..86f3e72164c0b180689381e757e1e1807fa402f9 GIT binary patch literal 295 zcmV+?0oeYDP)Px#;Ymb6R7i=%l)VvyFbstsxs-0b0t3)eWQi<7n*o?63s6!}!U_!Fmfk6HCSYXA zHpfMkt<(4PW1kAhW;f>|B0h=d1T6r(t7>&Trzxe7C_W@)jWI!Gt+iA{G*5}Ja6}PF z{7sC!*tP{&>^}^EwbH|xuq@scV2v?|8C@uOE-yZT6J6RGb5mkz>!bt{f8SoT)@&E* zHL%Gze(V(x}S` tTks_R6PlaSUo4dGD*g))!6^b!{0GG}dSy`e!_NQ!002ovPDHLkV1ku$c4zPx$1W80eR9J=Wmb($cFbsxeE@fJ;zyN6}Sb{}p8Gvb6AY}?lvH}CRr8fmQ*s=VQ zfIA1&MCa)HrPzvX0cq2gFEZla&2u%PY)ni80GzsR8rY3vNfBJ0tC7WqM6^;Ws4S&S z6~N`W8rdu+5QQ@e;PPCJB6gLGT)5d2puycR0AQLa&qplC8_xD4JXM3lHU>6rllaJB z@iE?R$v}HLZA;J~=Qsb4BY+ioJ8mYwJ)GzN-^clc204FY2gQ_u cB2Wc?2N$UV$PScb+5i9m07*qoM6N<$f|Is~RR910 literal 0 HcmV?d00001 diff --git a/web/public/mascot/react-joy.png b/web/public/mascot/react-joy.png new file mode 100644 index 0000000000000000000000000000000000000000..c5c86d27c6657fb6a48be992e9e3fab31a95c4e7 GIT binary patch literal 379 zcmV->0fhdEP)Px$HAzH4R9J=Gmc0>zFdW4n&R|yX&Qb~+3mZ=tuyG8QZ~}L*aREIW7l6{z0sfQ} zcCnBkA0Zzkp5VUKA?bp2UZ={3Rqcxmvb*QA&u z4iYYm+ZJ)#Y_L3^D{ysv8BzcMz<4#Q1oxaDFx{=a>$~tb08C33fNOw0Ho%tOYXC#k zi!&hT`3bM9&iuaa0j>f5Yy;}IDGs3W9SSf>pY#?vaQ(;V5l!mfZv%WE+D) Z=wIx5CB+bWK{@~c002ovPDHLkV1i|Gq6Yu~ literal 0 HcmV?d00001 diff --git a/web/public/mascot/react-sigh.png b/web/public/mascot/react-sigh.png new file mode 100644 index 0000000000000000000000000000000000000000..20376a842aa778f01c17efc5be74e52e3d60fc69 GIT binary patch literal 365 zcmV-z0h0cSP)Px$CrLy>R9J=GmMs#(FcgHh=t^=04p3Wx#3Hyz9m4@S90q}{L?Gb`93aU0ARy3& zm*j;^pk-Ai&9~jY{4^Q-Qk{0j7(X;Q^SeGs0l=;(o*k?6nZpGINXWDD+YFVI(&{XP z;Dm`SU<}XBPZ>F3$|NR00zN4J#5*!v-T;s1%JMjt2LP=1Q>TYkw*ZPVeN^hLO{m9n z1Ek`)(gjdbN^q8r?d3z`x63O4{{(usfHrOG5|?U*wk}zTxB+~M<$d{k1rS15XW?|n zk#CO3H%Dh!lMg9i3f`ArpQNml(|lP7e^@LqZlKnxBs7zKX=aTfz51g{w200000 LNkvXXu0mjfO)QtI literal 0 HcmV?d00001 diff --git a/web/public/mascot/react-surprise.png b/web/public/mascot/react-surprise.png new file mode 100644 index 0000000000000000000000000000000000000000..72281079bded28c5862d91f0e83da8204956ff24 GIT binary patch literal 361 zcmV-v0ha!WP)Px$BS}O-R9J=Gmaz?kFc3wLC?%~|U;wA2pkx6Sq0InH!vd5PDZ>g3;FjJgf)gM% z=ktLC`4UoOc>kk|IUfKnJ-I@J^0kz%d)#c)p$wla`UGJur><9 zOKj1sI10))1<*<_!c}v!TIPXU`&T9g}zK+s8Uh@hv#Pdc+8GLA%wGv&o+jv zX;#Cy{F>tX1AQhW-!vcc7F--hyYg+G!z~+TMc68TZ4f$)I6DWE=>bvbF>`i)_c`F` zS`Hu@0A`KERMxU)8(z-Ne~tPwSq>JqwSVQ`faPI|!72C`BPk6@G>e;X00000NkvXX Hu0mjf->REB literal 0 HcmV?d00001 diff --git a/web/public/mascot/react-yell.png b/web/public/mascot/react-yell.png new file mode 100644 index 0000000000000000000000000000000000000000..477cc354fd0570f5842b0cba2ca34a7bcfed2da2 GIT binary patch literal 346 zcmV-g0j2(lP)Px$6iGxuR9J=Gmc0>!Fbsu7j*`|ZFaRkjC|Mwj&}IOp$pVxVDZ>g3;FjJg5{EzA zvMjiRr~;q9_jEcV6agM;@*H{OZ^?7LQ4N=<0sweq+3(=ib(CT_JlES>!zQMrlvZUS z1Xc)#=X#SgOhQiBqYw_y^(Jdv&`f50jaJWlv%VVB1}K;h3~CWZo)8>1*k3n5Nh!hS z>e$S;;29C@kA4DLbf|Oas}zPhCmC>fuD6dyJbe*DSiAUY6DXR-X~yMUk=&nXPKdvr zq~t5On8dsCZCS##7-mh_%0I>uj}V-jgN2>}QRr{x?ELO~z|mv1i2=ZDkm$}D&Wu~& s$@x*JuhQjWVLJO){sS12DFUb9-~7+*IE#A|F#rGn07*qoM6N<$f}hruU;qFB literal 0 HcmV?d00001 diff --git a/web/public/mascot/sleep.png b/web/public/mascot/sleep.png new file mode 100644 index 0000000000000000000000000000000000000000..4d96ae576cba8c32e6445b1c8d32f868084b030e GIT binary patch literal 331 zcmV-R0kr;!P)Px$1xZ9fR9J=Gmc0>!Fbsu7E~Q(qzyP!qSt5(jG62(L0m>AVVFdC(g& z1Ht%|h!yVG#1;cV`4^V7JXiz6d$cA6;*SS_R8zyW{+>NV6uuq+Dy6{X)3d}D1JU^G z7SK)gRSsQ|LSN-1d%!hDQ87qPXVHAK$OiQfTMwE{414A#jUPc7-xXfh{4y# zh||$y!p=~yw?N|g56?w3{$BTh!w%5ANg=>=FKS_8jl4x3y#6QD6{&fyQfp_e{}d3( dO9T#4{|4L?0s_j!Kf?e3002ovPDHLkV1jNchD-ne literal 0 HcmV?d00001 diff --git a/web/public/mascot/walk.png b/web/public/mascot/walk.png new file mode 100644 index 0000000000000000000000000000000000000000..3ebb4d47cacc47143b41ea19b42c56bcf6e7e16e GIT binary patch literal 375 zcmV--0f_#IP)Px$F-b&0R9J<@mN6QFFbstsevYoaf(KxAaP$IR#MJ|M8ZY4J;3!w%09oDRVxdA> z+5l}P2+8}CKuar38#~ukMEugWDnBI10C2DC&T(!UZ6TT_vhaOEOhjHaV@zxj+9tB` zC6ORJp$KggS^1kulFlZZ_&w)fPzJs`0G0~+O=@(#f)(N00Wc9E=FPx$KuJVFR9J<@ma&<_FbsuX_^zb&05lnZmV%N6SR^e2q`gIM0ZNK1VE~#8pq5@V zIG7+imXp}3632S~lM*|wFdpn&R}u5)jz#$)K?Z=o?Y18{-fpQzQi{mJ_X#l(dDVV(2;Vk(^b literal 0 HcmV?d00001 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'} +
+ {/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}'] + } +})