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.
+ 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
+
+
+
+
+
+
+
+
+
+
+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.
+
+
+
+
+
+ Ready
+ Thinking
+ Progress
+ Done
+
+ 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.
+
+
+
+
+
+ Replay
+ Pause
+ Resume
+ Stop
+
+ 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.
+
+
+
+
+
+ Ambient ready
+ Thinking
+ Done
+
+ 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
+
+
+
+
+
+ Skip to the live Studio
+
+
+
+
+
+
+
+
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.
+
+
+
+ 01
+ Deterministic identity
+
+ A seed preserves the character of the field while signals and interaction
+ change its energy.
+
+
+
+ 02
+ Semantic states
+
+ Ready, thinking, tool use, completion, and recovery share one coherent
+ visual language.
+
+
+
+ 03
+ 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.
+
+
+
+
+
+
+
+
+
+
+ Find a state
+
+
+
+ State preset
+
+
+ Loading states…
+
+
+
+
+ 25 states available
+
+
+
+
+
+ Recipe base
+ Ready to customize
+
+
+
+ Recent saved presets
+
+ Recent presets
+
+
+
+ Save preset
+
+
+ Undo
+
+
+ Reset
+
+
+ Export…
+
+
+
+
+
+
+
+ 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.
+
+
+
+ Fit
+
+
+ 1:1
+
+
+
+
+
+
+ Semantic progress
+ 48%
+
+
+
+
+
+
+
Quick states
+
+ Idle
+ Field
+
+ Symmetry
+
+ Think
+ Tool
+ Done
+
+
+
+
+
+
FPS
+ —
+
+
+
Pixels
+ —
+
+
+
State
+ ai.idle
+
+
+
+
+
+ Space motion ← → states R reseed
+
+
+ Pause motion
+
+
+ Auto-play AI states
+
+
+ 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.
+
+
+
+
+
+ 01
+
+
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
+});
+
+
+
+ 02
+
+
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;
+
+
+
+ 03
+
+
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");
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+ No presets saved yet. Tune a state, then choose Save current. It will stay
+ available locally in this browser.
+
+
+
+ Preset deleted.
+
+ Undo delete
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+ Prepare PNG
+
+
+ Prepare SVG
+
+
+ Prepare animated SVG
+
+
+ Prepare recipe
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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;