From 3cd4cf98c39b15f8d376d60a0dc00a34eea16d92 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 5 Aug 2026 17:27:04 +0200 Subject: [PATCH] chore: vendor @joan/procedural-glyph-engine for portable builds Move @joan/procedural-glyph-engine from absolute path dep (/private/tmp/orby-pkg) to vendored local dep (web/vendor/) so the SPA builds in Docker and on other machines without the temp dir. - Vendor Orby v5.0.0 into web/vendor/ - Switch package.json dep to file:../vendor - Update Dockerfile to COPY vendor into build context - Use npm install instead of npm ci (file: deps need install) - Fix missing trailing newline in Dockerfile --- compose/web/Dockerfile | 3 +- web/package-lock.json | 660 ++- web/package.json | 2 +- 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 + 40 files changed, 27241 insertions(+), 325 deletions(-) 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 diff --git a/compose/web/Dockerfile b/compose/web/Dockerfile index c2c6249..afbff7a 100644 --- a/compose/web/Dockerfile +++ b/compose/web/Dockerfile @@ -8,7 +8,8 @@ FROM node:22-alpine AS builder WORKDIR /build/web COPY web/package.json web/package-lock.json ./ -RUN npm ci +COPY web/vendor /build/vendor +RUN npm install --no-audit --no-fund COPY VERSION ./ COPY web/ ./ RUN npm run build diff --git a/web/package-lock.json b/web/package-lock.json index 7b67983..c444ef9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "oikos-web", "version": "0.1.0", "dependencies": { - "@joan/procedural-glyph-engine": "file:../../../../../private/tmp/orby-pkg", + "@joan/procedural-glyph-engine": "file:../vendor", "@surdeddd/wmkit": "^0.3.0", "clsx": "^2.1.1", "d3-force": "^3.0.0", @@ -44,14 +44,7 @@ "vitest": "^2.0.0" } }, - "../../../../../private/tmp/orby-pkg": { - "name": "@joan/procedural-glyph-engine", - "version": "5.0.0", - "license": "SEE LICENSE IN LICENSE", - "engines": { - "node": ">=18" - } - }, + "../vendor": {}, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -624,9 +617,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "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": { @@ -781,30 +774,30 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "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.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "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.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "dev": true, "license": "MIT" }, @@ -875,9 +868,9 @@ } }, "node_modules/@internationalized/date": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", - "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "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": { @@ -885,7 +878,7 @@ } }, "node_modules/@joan/procedural-glyph-engine": { - "resolved": "../../../../../private/tmp/orby-pkg", + "resolved": "../vendor", "link": true }, "node_modules/@jridgewell/gen-mapping": { @@ -934,19 +927,36 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", - "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==", + "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.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "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" ], @@ -958,9 +968,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "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" ], @@ -972,9 +982,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "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" ], @@ -986,9 +996,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "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" ], @@ -1000,9 +1010,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "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" ], @@ -1014,9 +1024,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "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" ], @@ -1028,9 +1038,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "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" ], @@ -1042,9 +1052,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "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" ], @@ -1056,9 +1066,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "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" ], @@ -1070,9 +1080,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "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" ], @@ -1084,9 +1094,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "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" ], @@ -1098,9 +1108,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "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" ], @@ -1112,9 +1122,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "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" ], @@ -1126,9 +1136,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "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" ], @@ -1140,9 +1150,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "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" ], @@ -1154,9 +1164,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "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" ], @@ -1168,9 +1178,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "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" ], @@ -1182,9 +1192,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "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" ], @@ -1196,9 +1206,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "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" ], @@ -1210,9 +1220,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "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" ], @@ -1224,9 +1234,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "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" ], @@ -1238,9 +1248,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "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" ], @@ -1252,9 +1262,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "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" ], @@ -1266,9 +1276,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "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" ], @@ -1280,9 +1290,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "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" ], @@ -1336,9 +1346,9 @@ } }, "node_modules/@sveltejs/load-config": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", - "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "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": { @@ -1396,49 +1406,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "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.21.6", + "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.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "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.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@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.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "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" ], @@ -1453,9 +1463,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "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" ], @@ -1470,9 +1480,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "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" ], @@ -1487,9 +1497,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "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" ], @@ -1504,9 +1514,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "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" ], @@ -1521,9 +1531,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "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" ], @@ -1538,9 +1548,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "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" ], @@ -1555,9 +1565,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "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" ], @@ -1572,9 +1582,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "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" ], @@ -1589,9 +1599,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "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", @@ -1619,9 +1629,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "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" ], @@ -1636,9 +1646,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "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" ], @@ -1653,15 +1663,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "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.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -1701,17 +1711,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", - "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "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.64.0", - "@typescript-eslint/type-utils": "8.64.0", - "@typescript-eslint/utils": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", + "@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" @@ -1724,7 +1734,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.64.0", + "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1740,16 +1750,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", - "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "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.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", + "@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": { @@ -1765,14 +1775,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", - "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "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.64.0", - "@typescript-eslint/types": "^8.64.0", + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "engines": { @@ -1787,14 +1797,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", - "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "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.64.0", - "@typescript-eslint/visitor-keys": "8.64.0" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1805,9 +1815,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", - "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "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": { @@ -1822,15 +1832,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", - "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "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.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0", + "@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" }, @@ -1847,9 +1857,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", - "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "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": { @@ -1861,16 +1871,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", - "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "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.64.0", - "@typescript-eslint/tsconfig-utils": "8.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", + "@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", @@ -1899,26 +1909,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "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": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "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.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -1928,16 +1938,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", - "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "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.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0" + "@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" @@ -1952,13 +1962,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", - "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "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.64.0", + "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2079,9 +2089,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "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" @@ -2218,9 +2228,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "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": { @@ -2558,15 +2568,15 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "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.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "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" @@ -2588,9 +2598,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "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": { @@ -2904,9 +2914,9 @@ } }, "node_modules/esrap": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", - "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "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" @@ -3057,9 +3067,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "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" }, @@ -3397,9 +3407,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "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": [ { @@ -3856,9 +3866,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "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" @@ -3931,9 +3941,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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": [ { @@ -4097,9 +4107,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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": [ { @@ -4117,7 +4127,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4234,9 +4244,9 @@ } }, "node_modules/prettier": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -4295,9 +4305,9 @@ } }, "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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": { @@ -4311,31 +4321,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", + "@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" } }, @@ -4508,9 +4519,9 @@ } }, "node_modules/svelte": { - "version": "5.56.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", - "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "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", @@ -4535,14 +4546,14 @@ } }, "node_modules/svelte-check": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", - "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "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.0", + "@sveltejs/load-config": "^0.2.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", @@ -4556,7 +4567,7 @@ }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" + "typescript": "^5.0.0 || ^6.0.0" } }, "node_modules/svelte-eslint-parser": { @@ -4720,13 +4731,13 @@ } }, "node_modules/tailwind-variants": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", - "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "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.x", + "node": ">=16.9.x", "pnpm": ">=7.x" }, "peerDependencies": { @@ -4736,13 +4747,16 @@ "peerDependenciesMeta": { "tailwind-merge": { "optional": true + }, + "tailwindcss": { + "optional": true } } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "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" }, @@ -4915,16 +4929,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", - "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "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.64.0", - "@typescript-eslint/parser": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0" + "@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" @@ -6257,9 +6271,9 @@ } }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "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": { diff --git a/web/package.json b/web/package.json index cadaed4..064f76c 100644 --- a/web/package.json +++ b/web/package.json @@ -42,7 +42,7 @@ "vitest": "^2.0.0" }, "dependencies": { - "@joan/procedural-glyph-engine": "file:../../../../../private/tmp/orby-pkg", + "@joan/procedural-glyph-engine": "file:../vendor", "@surdeddd/wmkit": "^0.3.0", "clsx": "^2.1.1", "d3-force": "^3.0.0", 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;