From 5adb282c664b680c9ad80eeb16844e178ac2eef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 03:31:45 +0000 Subject: [PATCH 1/4] Fix JAML seed finder for motely-wasm 26 + jaml-ui 4.6 The JAML view was broken against current deps: jaml-ui dropped useSearch, JamlSpeedometer, JamlAestheticSelector and setJamlAssetBaseUrl, and motely-wasm 26 removed Search.scoreList in favor of the settings builder. - Bump motely-wasm to ^26.0.0 and jaml-ui to ^4.6.0 - Add Uint8Array.fromBase64 polyfill: motely-wasm 26 boots in embedded mode through it, and it is undefined in Node 22 and most browsers, so boot() would throw before any search ran - Rewrite JamlView on the motely-wasm 26 API: boot once, subscribe to Search.onScored/onProgress, drive Search.settings(jaml).withRandomSearch/ withSequentialSearch().stopAfter().start(token); JamlIde for authoring, a scope/progress sidebar, and live cancellation - Drop setJamlAssetBaseUrl call and import jaml-ui fonts/jimbo styles - .npmrc legacy-peer-deps for the motely-wasm 26 / jaml-ui peer range Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Jw8Fz76cjSbxhBjhGohfBG --- .npmrc | 1 + package-lock.json | 239 +++++++------- package.json | 4 +- src/App.tsx | 5 +- .../blueprint/jamlView/JamlView.tsx | 302 ++++++++++++++---- src/main.jsx | 1 + src/modules/uint8Base64Polyfill.ts | 72 +++++ 7 files changed, 423 insertions(+), 201 deletions(-) create mode 100644 .npmrc create mode 100644 src/modules/uint8Base64Polyfill.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/package-lock.json b/package-lock.json index b08648a..caf3f67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,10 @@ "@tanstack/react-query": "^5.85.9", "embla-carousel-react": "^8.6.0", "immer": "^10.1.3", - "jaml-ui": "^0.14.0", + "jaml-ui": "^4.6.0", "js-convert-case": "^4.2.0", "js-yaml": "^4.1.1", - "motely-wasm": "^13.0.3", + "motely-wasm": "^26.0.0", "motion": "^12.29.2", "react": "^18.0.0", "react-dom": "^18.0.0", @@ -315,7 +315,7 @@ }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", - "devOptional": true, + "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@emnapi/core": { @@ -1018,6 +1018,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@json-render/core": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@json-render/core/-/core-0.20.0.tgz", + "integrity": "sha512-cXAXn7h3QaylefQgh85AEGPuVq4C8SJ3k9JqigWLJhaBSozF1Fmsrm43DP16RmkqNiVgj5pBRdmB48lEO5qlow==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.6" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@json-render/react": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@json-render/react/-/react-0.20.0.tgz", + "integrity": "sha512-arRrlf7zeIJYM6i2JHcTBKW5Kl+ErBtoNR29h4i6rG4AtTzLmaWDgPTs35pr3MdSCTp0UxKwY4O3vWMdUihpRQ==", + "license": "Apache-2.0", + "dependencies": { + "@json-render/core": "0.20.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, "node_modules/@mantine/carousel": { "version": "8.3.14", "license": "MIT", @@ -1087,31 +1111,6 @@ "react": "^18.x || ^19.x" } }, - "node_modules/@monaco-editor/loader": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", - "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", - "license": "MIT", - "peer": true, - "dependencies": { - "state-local": "^1.0.6" - } - }, - "node_modules/@monaco-editor/react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", - "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@monaco-editor/loader": "^1.5.0" - }, - "peerDependencies": { - "monaco-editor": ">= 0.25.0 < 1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1127,6 +1126,7 @@ }, "node_modules/@parcel/watcher": { "version": "2.5.6", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1166,6 +1166,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1186,6 +1187,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1206,6 +1208,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1226,6 +1229,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1246,6 +1250,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1266,6 +1271,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1286,6 +1292,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1306,6 +1313,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1326,6 +1334,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1346,6 +1355,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1366,6 +1376,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1386,6 +1397,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1404,6 +1416,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1913,12 +1926,12 @@ }, "node_modules/@types/prop-types": { "version": "15.7.14", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.18", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1933,14 +1946,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.1", "dev": true, @@ -2943,6 +2948,7 @@ }, "node_modules/chokidar": { "version": "4.0.3", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2980,7 +2986,7 @@ }, "node_modules/colorjs.io": { "version": "0.5.2", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/comment-parser": { @@ -3027,7 +3033,7 @@ }, "node_modules/csstype": { "version": "3.1.3", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -3139,6 +3145,7 @@ }, "node_modules/detect-libc": { "version": "2.1.2", + "dev": true, "license": "Apache-2.0", "optional": true, "engines": { @@ -3160,16 +3167,6 @@ "node": ">=0.10.0" } }, - "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "dev": true, @@ -4170,7 +4167,7 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4268,7 +4265,7 @@ }, "node_modules/immutable": { "version": "5.1.5", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/import-fresh": { @@ -4428,7 +4425,7 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4468,7 +4465,7 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4667,46 +4664,36 @@ "node": ">= 0.4" } }, + "node_modules/jaml-lang": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/jaml-lang/-/jaml-lang-3.14.2.tgz", + "integrity": "sha512-n/gceNe8W8MRgLmWnqzvgRu5Yv+cqkouXpz3f5jnujgkgVXLkAdXahUsDUTOjXAF6fvUTTw8xzqoDF38AOT1IA==", + "license": "MIT" + }, "node_modules/jaml-ui": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/jaml-ui/-/jaml-ui-0.14.0.tgz", - "integrity": "sha512-COrGyM0zTQk6hLtC89VNVVw8zvBWpPp5imL5iAwO/edrPNZacifHBupcmiRakzjtzTenVrioAXi9aY9316oIfw==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/jaml-ui/-/jaml-ui-4.6.0.tgz", + "integrity": "sha512-RzqTz/tqK5xj+SN+5HPA2Ej0xRYfg0+6GQTQuJjxQDy9APHnfIf+wS8OrM/0kXpiCZ+W1u9DcK97f3pKtK4yOQ==", "license": "MIT", + "dependencies": { + "@json-render/core": "^0.20.0", + "@json-render/react": "^0.20.0", + "jaml-lang": "^3.14.2", + "react-icons": "^5.7.0", + "zod": "^4.5.4" + }, "engines": { "node": ">=18" }, "peerDependencies": { - "@monaco-editor/react": ">=4.0.0", - "@react-spring/three": ">=9.0.0", - "@react-three/fiber": ">=8.0.0", - "monaco-editor": ">=0.50.0", - "motely-wasm": "^10.2.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "motely-wasm": "^25.1.0", "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0", - "react-icons": ">=5.0.0", - "three": ">=0.150.0" - }, - "peerDependenciesMeta": { - "@react-spring/three": { - "optional": true - }, - "@react-three/fiber": { - "optional": true - }, - "motely-wasm": { - "optional": true - }, - "react-icons": { - "optional": true - }, - "three": { - "optional": true - } + "react-dom": "^18.2.0 || ^19.0.0" } }, "node_modules/jiti": { "version": "2.6.1", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -4849,19 +4836,6 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "dev": true, @@ -4883,21 +4857,10 @@ "node": "*" } }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", - "license": "MIT", - "peer": true, - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, "node_modules/motely-wasm": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/motely-wasm/-/motely-wasm-13.0.3.tgz", - "integrity": "sha512-6+PmdJCT33BTu8xIRfQ/KLtG3B+yj+C0wmIIcpA4YhjQldSv/6PY5auUulwYzDdnA/rj1RQpXIxt0qcNQ/JpPw==" + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/motely-wasm/-/motely-wasm-26.0.0.tgz", + "integrity": "sha512-p4QEal6p7WESkgFuiUQyGyJmdqatIxxDttkduG2RvCWhqJcfkrtHhqeFKLXE4tffsmBRpgY3shhOxxiQwJHisg==" }, "node_modules/motion": { "version": "12.33.0", @@ -4975,6 +4938,7 @@ }, "node_modules/node-addon-api": { "version": "7.1.1", + "dev": true, "license": "MIT", "optional": true }, @@ -5423,6 +5387,15 @@ "react": ">=16.13.1" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "dev": true, @@ -5524,6 +5497,7 @@ }, "node_modules/readdirp": { "version": "4.1.2", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -5652,7 +5626,7 @@ }, "node_modules/rxjs": { "version": "7.8.2", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -5709,6 +5683,7 @@ }, "node_modules/sass": { "version": "1.99.0", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5728,7 +5703,7 @@ }, "node_modules/sass-embedded": { "version": "1.99.0", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@bufbuild/protobuf": "^2.5.0", @@ -5776,6 +5751,7 @@ "!riscv64", "!x64" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5789,6 +5765,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5805,6 +5782,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5821,6 +5799,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5837,6 +5816,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5853,6 +5833,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5869,6 +5850,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5885,6 +5867,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5901,6 +5884,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5917,6 +5901,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5933,6 +5918,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5949,6 +5935,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5965,6 +5952,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5981,6 +5969,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5997,6 +5986,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6010,6 +6000,7 @@ "version": "1.99.0", "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.99.0.tgz", "integrity": "sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==", + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6029,6 +6020,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6043,6 +6035,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6054,7 +6047,7 @@ }, "node_modules/sass-embedded/node_modules/supports-color": { "version": "8.1.1", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -6234,13 +6227,6 @@ "version": "0.0.2", "license": "MIT" }, - "node_modules/state-local": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", - "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", - "license": "MIT", - "peer": true - }, "node_modules/std-env": { "version": "3.9.0", "license": "MIT" @@ -6372,7 +6358,7 @@ }, "node_modules/sugarss": { "version": "5.0.1", - "devOptional": true, + "dev": true, "funding": [ { "type": "opencollective", @@ -6415,7 +6401,7 @@ }, "node_modules/sync-child-process": { "version": "1.0.2", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "sync-message-port": "^1.0.0" @@ -6426,7 +6412,7 @@ }, "node_modules/sync-message-port": { "version": "1.2.0", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -6824,7 +6810,7 @@ }, "node_modules/varint": { "version": "6.0.0", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/vite": { @@ -7172,8 +7158,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "dev": true, + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 024efdf..e10df94 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ "@tanstack/react-query": "^5.85.9", "embla-carousel-react": "^8.6.0", "immer": "^10.1.3", - "jaml-ui": "^0.14.0", + "jaml-ui": "^4.6.0", "js-convert-case": "^4.2.0", "js-yaml": "^4.1.1", - "motely-wasm": "^13.0.3", + "motely-wasm": "^26.0.0", "motion": "^12.29.2", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/src/App.tsx b/src/App.tsx index 5916cc8..e833f58 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,8 @@ import "@mantine/core/styles.css"; import '@mantine/code-highlight/styles.css'; import '@mantine/carousel/styles.css'; import '@mantine/spotlight/styles.css'; +import "jaml-ui/fonts.css"; +import "jaml-ui/jimbo.css"; import { MantineProvider } from "@mantine/core"; @@ -12,9 +14,6 @@ import { SeedOptionsProvider } from "./modules/state/optionsProvider.tsx"; import { DownloadSeedResultProvider } from "./modules/state/downloadProvider.tsx"; import { BlueprintThemeProvider, useBlueprintTheme } from "./modules/state/themeProvider.tsx"; import { JamlSearchProvider } from "./modules/state/jamlSearchContext.tsx"; -import { setJamlAssetBaseUrl } from "jaml-ui"; - -setJamlAssetBaseUrl("./images"); const queryClient = new QueryClient() diff --git a/src/components/blueprint/jamlView/JamlView.tsx b/src/components/blueprint/jamlView/JamlView.tsx index 6bf4db6..9105a39 100644 --- a/src/components/blueprint/jamlView/JamlView.tsx +++ b/src/components/blueprint/jamlView/JamlView.tsx @@ -1,93 +1,255 @@ -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - JamlIde, - JamlSpeedometer, - JamlAestheticSelector, - JamlSeedInput, - useSearch, - type JamlAestheticOption, -} from "jaml-ui"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import motelyWasmUrl from "motely-wasm?url"; - -const DEFAULT_JAML = `name: Blueprint Copy Engine -author: jammy -description: Blueprint rare joker with Brainstorm for joker copying synergy -deck: Red -stake: White -must: - - rareJoker: Blueprint - antes: [1, 2, 3, 4] -should: - - rareJoker: Brainstorm - score: 80 - - rareJoker: Baron - score: 55 - - legendaryJoker: Triboulet - score: 55 - - uncommonJoker: OopsAll6s - score: 50 - - legendaryJoker: Perkeo - score: 50 - - uncommonJoker: Showman - score: 35 - - spectral: Hex - score: 35 - - mixedJoker: Any - edition: Negative - score: 40 - - tag: NegativeTag - score: 35 -`; + Badge, + Button, + Group, + NumberInput, + Paper, + Progress, + SegmentedControl, + Stack, + Text, +} from "@mantine/core"; +import { JamlIde, parseJaml } from "jaml-ui"; +import motely, { Search } from "motely-wasm"; +import { useJamlSearch } from "../../../modules/state/jamlSearchContext.tsx"; +import type { JamlIdeSearchResult } from "jaml-ui"; +import type { MotelyProgress, MotelySeedScore } from "motely-wasm"; + +// CancellationToken collides at the motely-wasm package root (re-exported from +// two submodules), so the token contract start() needs is reimplemented here. +class SearchCancellationToken { + isCancellationRequested = false; + private handlers = new Set<() => void>(); + onCancellationRequested = { + subscribe: (h: () => void) => this.handlers.add(h), + unsubscribe: (h: () => void) => this.handlers.delete(h), + }; + cancel() { + if (this.isCancellationRequested) return; + this.isCancellationRequested = true; + for (const h of this.handlers) h(); + } +} + +type Status = "idle" | "booting" | "running" | "done" | "error"; +type ScopeMode = "sequential" | "random"; + +const MAX_RESULTS = 200; + +interface SearchStats { + seedsSearched: number; + matchingSeeds: number; + seedsPerSecond: number; + percentComplete: number; +} + +const EMPTY_STATS: SearchStats = { + seedsSearched: 0, + matchingSeeds: 0, + seedsPerSecond: 0, + percentComplete: 0, +}; + +function formatNumber(value: number): string { + return value.toLocaleString(); +} export default function JamlView() { - const [jaml, setJaml] = useState(DEFAULT_JAML); - const [aesthetic, setAesthetic] = useState(null); - const [aestheticValue, setAestheticValue] = useState(0); - const search = useSearch(motelyWasmUrl); + const { jamlText, setJamlText } = useJamlSearch(); + + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [results, setResults] = useState>([]); + const [stats, setStats] = useState(EMPTY_STATS); + + const [scope, setScope] = useState("random"); + const [randomCount, setRandomCount] = useState(50_000); + const [stopAfter, setStopAfter] = useState(25); + + const tokenRef = useRef(null); + + const tallyLabels = useMemo(() => { + try { + return parseJaml(jamlText).should.map((clause) => clause.label); + } catch { + return []; + } + }, [jamlText]); - const isSearching = search.status === "running" || search.status === "booting"; + useEffect(() => { + return () => tokenRef.current?.cancel(); + }, []); - const handleSearch = () => { + const isSearching = status === "running" || status === "booting"; + + const runSearch = useCallback(async () => { + setError(null); + setResults([]); + setStats(EMPTY_STATS); + setStatus("booting"); + + const token = new SearchCancellationToken(); + tokenRef.current = token; + + const collected: Array = []; + const onScored = (score: MotelySeedScore) => { + collected.push(score); + collected.sort((a, b) => b.score - a.score); + if (collected.length > MAX_RESULTS) collected.length = MAX_RESULTS; + setResults([...collected]); + }; + const onProgress = (p: MotelyProgress) => { + setStats({ + seedsSearched: Number(p.seedsSearched), + matchingSeeds: Number(p.matchingSeeds), + seedsPerSecond: Math.round(p.seedsPerMillisecond * 1000), + percentComplete: p.percentComplete, + }); + }; + + Search.onScored.subscribe(onScored); + Search.onProgress.subscribe(onProgress); + + try { + if (motely.getStatus() !== motely.BootStatus.Booted) { + await motely.boot(); + } + setStatus("running"); + + let settings = Search.settings(jamlText).withProgressReportIntervalMs(250n); + settings = + scope === "random" + ? settings.withRandomSearch(Math.max(1, randomCount)) + : settings.withSequentialSearch(); + if (stopAfter > 0) settings = settings.stopAfter(BigInt(stopAfter)); + + await settings.start(token); + setStatus(token.isCancellationRequested ? "idle" : "done"); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setStatus("error"); + } finally { + Search.onScored.unsubscribe(onScored); + Search.onProgress.unsubscribe(onProgress); + tokenRef.current = null; + } + }, [jamlText, scope, randomCount, stopAfter]); + + const handleSearch = useCallback(() => { if (isSearching) { - search.cancel(); - } else if (aesthetic) { - search.startAesthetic(jaml, aestheticValue); + tokenRef.current?.cancel(); } else { - search.start(jaml, 10_000); + void runSearch(); } + }, [isSearching, runSearch]); + + const ideResults: Array = useMemo( + () => + results.map((r) => ({ + seed: r.seed, + score: r.score, + tallyColumns: Array.from(r.tally), + tallyLabels, + })), + [results, tallyLabels], + ); + + const statusLabel: Record = { + idle: "Ready", + booting: "Booting engine…", + running: "Searching…", + done: "Search complete", + error: "Error", }; const sidebar = ( -
- - - { setAesthetic(a); setAestheticValue(v); }} - /> -
+ + + + + {statusLabel[status]} + + {formatNumber(stats.matchingSeeds || results.length)} hits + + + + {formatNumber(stats.seedsSearched)} searched · {formatNumber(stats.seedsPerSecond)}/s + + {scope === "random" && ( + + )} + {error && ( + + {error} + + )} + + + + + + + Search scope + + setScope(v as ScopeMode)} + disabled={isSearching} + data={[ + { label: "Random", value: "random" }, + { label: "Sequential", value: "sequential" }, + ]} + /> + {scope === "random" && ( + setRandomCount(Number(v) || 0)} + min={1} + step={10_000} + thousandSeparator + disabled={isSearching} + /> + )} + setStopAfter(Number(v) || 0)} + min={0} + disabled={isSearching} + /> + + Deck & stake come from the JAML. Sequential walks the whole seed space; cancel any time. + + + + ); return ( -
-
+
+
-
+
+ {sidebar}
diff --git a/src/main.jsx b/src/main.jsx index a477958..7b32ef7 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,3 +1,4 @@ +import "./modules/uint8Base64Polyfill.ts"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; diff --git a/src/modules/uint8Base64Polyfill.ts b/src/modules/uint8Base64Polyfill.ts new file mode 100644 index 0000000..fc01dbf --- /dev/null +++ b/src/modules/uint8Base64Polyfill.ts @@ -0,0 +1,72 @@ +// motely-wasm 26 boots in embedded mode: the runtime is a base64 string that +// config.mjs turns into bytes with `Uint8Array.fromBase64`. That method is a +// very recent TC39 proposal — absent in Node 22 and in most shipping browsers — +// so without this polyfill `motely.boot()` throws before the search ever starts. +// Guarded so it no-ops wherever the engine's native implementation exists. + +interface Uint8ArrayBase64Statics { + fromBase64?: (input: string) => Uint8Array; +} +interface Uint8ArrayBase64Proto { + toBase64?: () => string; + setFromBase64?: (input: string) => { read: number; written: number }; +} + +const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +function decodeBase64(input: string): Uint8Array { + const clean = input.replace(/[\s=]+$/g, "").replace(/\s+/g, ""); + const lookup = new Int16Array(128).fill(-1); + for (let i = 0; i < B64.length; i++) lookup[B64.charCodeAt(i)] = i; + // tolerate base64url as well + lookup["-".charCodeAt(0)] = 62; + lookup["_".charCodeAt(0)] = 63; + + const out = new Uint8Array((clean.length * 3) >> 2); + let bits = 0; + let bitCount = 0; + let o = 0; + for (let i = 0; i < clean.length; i++) { + const v = lookup[clean.charCodeAt(i) & 0x7f]; + if (v < 0) continue; + bits = (bits << 6) | v; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + out[o++] = (bits >> bitCount) & 0xff; + } + } + return o === out.length ? out : out.subarray(0, o); +} + +const statics = Uint8Array as unknown as Uint8ArrayBase64Statics; +const proto = Uint8Array.prototype as unknown as Uint8ArrayBase64Proto; + +if (typeof statics.fromBase64 !== "function") { + statics.fromBase64 = decodeBase64; +} + +if (typeof proto.toBase64 !== "function") { + proto.toBase64 = function (this: Uint8Array): string { + let str = ""; + for (let i = 0; i < this.length; i += 3) { + const a = this[i]; + const b = i + 1 < this.length ? this[i + 1] : 0; + const c = i + 2 < this.length ? this[i + 2] : 0; + str += B64[a >> 2]; + str += B64[((a & 3) << 4) | (b >> 4)]; + str += i + 1 < this.length ? B64[((b & 15) << 2) | (c >> 6)] : "="; + str += i + 2 < this.length ? B64[c & 63] : "="; + } + return str; + }; +} + +if (typeof proto.setFromBase64 !== "function") { + proto.setFromBase64 = function (this: Uint8Array, input: string) { + const bytes = decodeBase64(input); + const written = Math.min(bytes.length, this.length); + this.set(bytes.subarray(0, written)); + return { read: input.length, written }; + }; +} From 325f82680b25166f294afea86a02d478629b662a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 03:50:56 +0000 Subject: [PATCH 2/4] Address Greptile review on the JAML seed finder - Boot the engine once on view mount via an ensureBooted() singleton, so init errors surface immediately and searches never re-boot (P2, CLAUDE.md "boot once on mount") - Truncate the sample-count and stop-after inputs and mark them integer-only so a decimal value can no longer throw in BigInt() and abort the search (P1) - Trim the base64 polyfill to just fromBase64 (the only method on motely's boot path); drop the unused toBase64/setFromBase64 globals (P2, no slop) - Document in .npmrc exactly which two peer combinations legacy-peer-deps covers and when to remove it, instead of silently disabling validation (P2) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Jw8Fz76cjSbxhBjhGohfBG --- .npmrc | 7 ++++ .../blueprint/jamlView/JamlView.tsx | 35 ++++++++++++++----- src/modules/uint8Base64Polyfill.ts | 32 +---------------- 3 files changed, 34 insertions(+), 40 deletions(-) diff --git a/.npmrc b/.npmrc index 521a9f7..263ea0f 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,8 @@ +# legacy-peer-deps: this project intentionally runs two peer combinations npm +# would otherwise reject: +# - jaml-ui@4.6 peers motely-wasm ^25.1, we run motely-wasm 26 (JamlView drives +# the engine directly and does not use jaml-ui's removed scoreList path) +# - @json-render/react peers React 19, this app is on React 18 +# Both are deliberate and verified working; keep this scoped by removing it once +# jaml-ui ships a motely-wasm 26 / React 18 compatible release. legacy-peer-deps=true diff --git a/src/components/blueprint/jamlView/JamlView.tsx b/src/components/blueprint/jamlView/JamlView.tsx index 9105a39..e3552bd 100644 --- a/src/components/blueprint/jamlView/JamlView.tsx +++ b/src/components/blueprint/jamlView/JamlView.tsx @@ -32,6 +32,13 @@ class SearchCancellationToken { } } +let bootPromise: Promise | null = null; +function ensureBooted() { + if (motely.getStatus() === motely.BootStatus.Booted) return Promise.resolve(); + if (!bootPromise) bootPromise = motely.boot(); + return bootPromise; +} + type Status = "idle" | "booting" | "running" | "done" | "error"; type ScopeMode = "sequential" | "random"; @@ -67,8 +74,16 @@ export default function JamlView() { const [randomCount, setRandomCount] = useState(50_000); const [stopAfter, setStopAfter] = useState(25); + const [engineError, setEngineError] = useState(null); const tokenRef = useRef(null); + // Boot the engine once when the view mounts so init errors surface immediately. + useEffect(() => { + ensureBooted().catch((e) => + setEngineError(e instanceof Error ? e.message : String(e)), + ); + }, []); + const tallyLabels = useMemo(() => { try { return parseJaml(jamlText).should.map((clause) => clause.label); @@ -112,17 +127,17 @@ export default function JamlView() { Search.onProgress.subscribe(onProgress); try { - if (motely.getStatus() !== motely.BootStatus.Booted) { - await motely.boot(); - } + await ensureBooted(); setStatus("running"); + const sampleCount = Math.max(1, Math.trunc(randomCount)); + const matchLimit = Math.max(0, Math.trunc(stopAfter)); let settings = Search.settings(jamlText).withProgressReportIntervalMs(250n); settings = scope === "random" - ? settings.withRandomSearch(Math.max(1, randomCount)) + ? settings.withRandomSearch(sampleCount) : settings.withSequentialSearch(); - if (stopAfter > 0) settings = settings.stopAfter(BigInt(stopAfter)); + if (matchLimit > 0) settings = settings.stopAfter(BigInt(matchLimit)); await settings.start(token); setStatus(token.isCancellationRequested ? "idle" : "done"); @@ -179,9 +194,9 @@ export default function JamlView() { {scope === "random" && ( )} - {error && ( + {(engineError || error) && ( - {error} + {engineError ? `Engine failed to boot: ${engineError}` : error} )} @@ -206,9 +221,10 @@ export default function JamlView() { setRandomCount(Number(v) || 0)} + onChange={(v) => setRandomCount(Math.trunc(Number(v) || 0))} min={1} step={10_000} + allowDecimal={false} thousandSeparator disabled={isSearching} /> @@ -216,8 +232,9 @@ export default function JamlView() { setStopAfter(Number(v) || 0)} + onChange={(v) => setStopAfter(Math.trunc(Number(v) || 0))} min={0} + allowDecimal={false} disabled={isSearching} /> diff --git a/src/modules/uint8Base64Polyfill.ts b/src/modules/uint8Base64Polyfill.ts index fc01dbf..6e9d1c8 100644 --- a/src/modules/uint8Base64Polyfill.ts +++ b/src/modules/uint8Base64Polyfill.ts @@ -2,15 +2,12 @@ // config.mjs turns into bytes with `Uint8Array.fromBase64`. That method is a // very recent TC39 proposal — absent in Node 22 and in most shipping browsers — // so without this polyfill `motely.boot()` throws before the search ever starts. +// Only `fromBase64` is on that boot path, so only `fromBase64` is polyfilled. // Guarded so it no-ops wherever the engine's native implementation exists. interface Uint8ArrayBase64Statics { fromBase64?: (input: string) => Uint8Array; } -interface Uint8ArrayBase64Proto { - toBase64?: () => string; - setFromBase64?: (input: string) => { read: number; written: number }; -} const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -40,33 +37,6 @@ function decodeBase64(input: string): Uint8Array { } const statics = Uint8Array as unknown as Uint8ArrayBase64Statics; -const proto = Uint8Array.prototype as unknown as Uint8ArrayBase64Proto; - if (typeof statics.fromBase64 !== "function") { statics.fromBase64 = decodeBase64; } - -if (typeof proto.toBase64 !== "function") { - proto.toBase64 = function (this: Uint8Array): string { - let str = ""; - for (let i = 0; i < this.length; i += 3) { - const a = this[i]; - const b = i + 1 < this.length ? this[i + 1] : 0; - const c = i + 2 < this.length ? this[i + 2] : 0; - str += B64[a >> 2]; - str += B64[((a & 3) << 4) | (b >> 4)]; - str += i + 1 < this.length ? B64[((b & 15) << 2) | (c >> 6)] : "="; - str += i + 2 < this.length ? B64[c & 63] : "="; - } - return str; - }; -} - -if (typeof proto.setFromBase64 !== "function") { - proto.setFromBase64 = function (this: Uint8Array, input: string) { - const bytes = decodeBase64(input); - const written = Math.min(bytes.length, this.length); - this.set(bytes.subarray(0, written)); - return { read: input.length, written }; - }; -} From 672ec114fcb00957c5ca77dc0e3b58dcabe2495b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 04:04:06 +0000 Subject: [PATCH 3/4] Rebuild JAML search sidebar from jaml-ui components Greptile flagged the search sidebar for using Mantine primitives against CLAUDE.md's "use jaml-ui components instead of building custom UI". Replace them with the Jimbo design system already installed via jaml-ui 4.6: - Paper -> JimboPanel, Text -> JimboText, Badge -> JimboBadge - status line -> JimboStatusPill, Group/Stack -> JimboRow/JimboStack - SegmentedControl scope toggle -> a JimboButton pair - NumberInput -> JimboTextInput with integer parsing (kept the decimal guard) - Progress bar -> a percent readout in the status line (no Jimbo progress primitive in 4.6) - Start/Cancel button -> JimboButton Search orchestration is unchanged. Build and lint pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Jw8Fz76cjSbxhBjhGohfBG --- .../blueprint/jamlView/JamlView.tsx | 154 ++++++++++-------- 1 file changed, 83 insertions(+), 71 deletions(-) diff --git a/src/components/blueprint/jamlView/JamlView.tsx b/src/components/blueprint/jamlView/JamlView.tsx index e3552bd..7f53a96 100644 --- a/src/components/blueprint/jamlView/JamlView.tsx +++ b/src/components/blueprint/jamlView/JamlView.tsx @@ -1,19 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - Badge, - Button, - Group, - NumberInput, - Paper, - Progress, - SegmentedControl, - Stack, - Text, -} from "@mantine/core"; -import { JamlIde, parseJaml } from "jaml-ui"; + JamlIde, + JimboBadge, + JimboButton, + JimboPanel, + JimboRow, + JimboStack, + JimboStatusPill, + JimboText, + JimboTextInput, + parseJaml, +} from "jaml-ui"; import motely, { Search } from "motely-wasm"; import { useJamlSearch } from "../../../modules/state/jamlSearchContext.tsx"; -import type { JamlIdeSearchResult } from "jaml-ui"; +import type { JamlIdeSearchResult, JimboStatus } from "jaml-ui"; import type { MotelyProgress, MotelySeedScore } from "motely-wasm"; // CancellationToken collides at the motely-wasm package root (re-exported from @@ -177,72 +177,85 @@ export default function JamlView() { done: "Search complete", error: "Error", }; + const pillStatus: Record = { + idle: "idle", + booting: "running", + running: "running", + done: "ok", + error: "error", + }; + const hitTone = status === "error" ? "red" : status === "done" ? "green" : "blue"; + + const parseCount = (v: string) => Math.max(0, Math.trunc(Number(v.replace(/[^\d]/g, "")) || 0)); const sidebar = ( - - - - - {statusLabel[status]} - + + + + + + {formatNumber(stats.matchingSeeds || results.length)} hits - - - + + + {formatNumber(stats.seedsSearched)} searched · {formatNumber(stats.seedsPerSecond)}/s - - {scope === "random" && ( - - )} + {scope === "random" && isSearching ? ` · ${Math.round(stats.percentComplete)}%` : ""} + {(engineError || error) && ( - + {engineError ? `Engine failed to boot: ${engineError}` : error} - + )} - - + + - - - - Search scope - - setScope(v as ScopeMode)} - disabled={isSearching} - data={[ - { label: "Random", value: "random" }, - { label: "Sequential", value: "sequential" }, - ]} - /> + + + + setScope("random")} + > + Random + + setScope("sequential")} + > + Sequential + + {scope === "random" && ( - setRandomCount(Math.trunc(Number(v) || 0))} - min={1} - step={10_000} - allowDecimal={false} - thousandSeparator + + Seeds to sample + setRandomCount(Math.max(1, parseCount(e.currentTarget.value)))} + disabled={isSearching} + /> + + )} + + Stop after N matches (0 = unlimited) + setStopAfter(parseCount(e.currentTarget.value))} disabled={isSearching} /> - )} - setStopAfter(Math.trunc(Number(v) || 0))} - min={0} - allowDecimal={false} - disabled={isSearching} - /> - + + Deck & stake come from the JAML. Sequential walks the whole seed space; cancel any time. - - - - + + + + ); return ( @@ -258,15 +271,14 @@ export default function JamlView() { subtitle={statusLabel[status]} />
-
- + {sidebar}
From c09f690626bc947e7624444ab6703016261d603f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:46:11 +0000 Subject: [PATCH 4/4] Move seed engine to a Web Worker; uncap results; expose full search surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine ran on the UI thread and froze the tab for seconds at a time (motely-wasm is single-threaded and blocks whatever realm it runs in). searchWorker.ts now owns the engine — boots it, drives Search directly, and streams scores/progress back over postMessage, so the UI stays live. - Remove the 200-result cap. Matches accumulate push-only in a ref and flush (sorted) on progress ticks and on done, so a heavy match count doesn't turn into an O(n^2) sort storm. - Expose the full motely-wasm 26 SearchSettings surface: random / sequential / keyword / aesthetic / seed-list sources, deck & stake overrides, auto score cutoff, stop-after, and batch controls (character count, provider batch seed count, start/end batch index). - Expert-scale defaults: 1,000,000-seed random sample, unlimited matches. - vite.config: worker.format 'es' so the worker code-splits (IIFE can't). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Jw8Fz76cjSbxhBjhGohfBG --- .../blueprint/jamlView/JamlView.tsx | 459 +++++++++++++----- .../blueprint/jamlView/searchWorker.ts | 139 ++++++ vite.config.js | 3 + 3 files changed, 472 insertions(+), 129 deletions(-) create mode 100644 src/components/blueprint/jamlView/searchWorker.ts diff --git a/src/components/blueprint/jamlView/JamlView.tsx b/src/components/blueprint/jamlView/JamlView.tsx index 7f53a96..8a54bad 100644 --- a/src/components/blueprint/jamlView/JamlView.tsx +++ b/src/components/blueprint/jamlView/JamlView.tsx @@ -11,38 +11,19 @@ import { JimboTextInput, parseJaml, } from "jaml-ui"; -import motely, { Search } from "motely-wasm"; +import { JamlAesthetic, MotelyDeck, MotelyStake } from "motely-wasm"; import { useJamlSearch } from "../../../modules/state/jamlSearchContext.tsx"; import type { JamlIdeSearchResult, JimboStatus } from "jaml-ui"; -import type { MotelyProgress, MotelySeedScore } from "motely-wasm"; - -// CancellationToken collides at the motely-wasm package root (re-exported from -// two submodules), so the token contract start() needs is reimplemented here. -class SearchCancellationToken { - isCancellationRequested = false; - private handlers = new Set<() => void>(); - onCancellationRequested = { - subscribe: (h: () => void) => this.handlers.add(h), - unsubscribe: (h: () => void) => this.handlers.delete(h), - }; - cancel() { - if (this.isCancellationRequested) return; - this.isCancellationRequested = true; - for (const h of this.handlers) h(); - } -} - -let bootPromise: Promise | null = null; -function ensureBooted() { - if (motely.getStatus() === motely.BootStatus.Booted) return Promise.resolve(); - if (!bootPromise) bootPromise = motely.boot(); - return bootPromise; -} +import type { MotelySeedScore } from "motely-wasm"; +import type { + SearchConfig, + SearchScope, + WorkerRequest, + WorkerResponse, +} from "./searchWorker.ts"; type Status = "idle" | "booting" | "running" | "done" | "error"; -type ScopeMode = "sequential" | "random"; - -const MAX_RESULTS = 200; +type ScopeMode = SearchScope["mode"]; interface SearchStats { seedsSearched: number; @@ -58,9 +39,30 @@ const EMPTY_STATS: SearchStats = { percentComplete: 0, }; -function formatNumber(value: number): string { - return value.toLocaleString(); +// Enum → [label, value] pairs. TS numeric enums carry a reverse mapping, so keep +// only the entries whose value is a number. +function enumOptions(e: Record): Array<[string, number]> { + return Object.entries(e) + .filter(([, v]) => typeof v === "number") + .map(([k, v]) => [k, v as number]); } +const DECK_OPTS = enumOptions(MotelyDeck); +const STAKE_OPTS = enumOptions(MotelyStake); +const AESTHETIC_OPTS = enumOptions(JamlAesthetic); + +const formatNumber = (value: number): string => value.toLocaleString(); +const parseCount = (v: string) => Math.max(0, Math.trunc(Number(v.replace(/[^\d]/g, "")) || 0)); + +const selectStyle: React.CSSProperties = { + width: "100%", + background: "var(--j-darkest, #1e2b2d)", + color: "var(--j-cream, #f4eee0)", + border: "1px solid var(--j-line, #38494b)", + borderRadius: 6, + padding: "6px 8px", + fontFamily: "var(--j-font-code, monospace)", + fontSize: 13, +}; export default function JamlView() { const { jamlText, setJamlText } = useJamlSearch(); @@ -70,20 +72,85 @@ export default function JamlView() { const [results, setResults] = useState>([]); const [stats, setStats] = useState(EMPTY_STATS); - const [scope, setScope] = useState("random"); - const [randomCount, setRandomCount] = useState(50_000); - const [stopAfter, setStopAfter] = useState(25); + // Search source (mutually exclusive engine modes). + const [scopeMode, setScopeMode] = useState("random"); + const [randomCount, setRandomCount] = useState(1_000_000); + const [keywords, setKeywords] = useState("CLAUDE,HAIKU,SONNET,OPUS,FABLE"); + const [quickPad, setQuickPad] = useState(true); + const [aesthetic, setAesthetic] = useState(JamlAesthetic.Palindrome); + const [seedList, setSeedList] = useState(""); - const [engineError, setEngineError] = useState(null); - const tokenRef = useRef(null); + // Overrides & tuning. null = leave to the JAML / engine default. + const [deck, setDeck] = useState(null); + const [stake, setStake] = useState(null); + const [stopAfter, setStopAfter] = useState(0); + const [autoScoreCutoff, setAutoScoreCutoff] = useState(false); + const [batchCharacterCount, setBatchCharacterCount] = useState(0); + const [providerBatchSeedCount, setProviderBatchSeedCount] = useState(0); + const [startBatchIndex, setStartBatchIndex] = useState(0); + const [endBatchIndex, setEndBatchIndex] = useState(0); + const [showAdvanced, setShowAdvanced] = useState(false); - // Boot the engine once when the view mounts so init errors surface immediately. - useEffect(() => { - ensureBooted().catch((e) => - setEngineError(e instanceof Error ? e.message : String(e)), - ); + const workerRef = useRef(null); + // Matches accumulate here (push-only, no per-event sort) and flush to state on + // progress ticks + on done — so a run that matches a lot of seeds doesn't turn + // into an O(n²) sort storm, and there is no artificial cap on how many are kept. + const collectedRef = useRef>([]); + + const flush = useCallback(() => { + const sorted = [...collectedRef.current].sort((a, b) => b.score - a.score); + setResults(sorted); }, []); + useEffect(() => { + const worker = new Worker(new URL("./searchWorker.ts", import.meta.url), { + type: "module", + }); + workerRef.current = worker; + + worker.onmessage = (ev: MessageEvent) => { + const msg = ev.data; + switch (msg.type) { + case "booting": + setStatus("booting"); + break; + case "running": + setStatus("running"); + break; + case "scored": + collectedRef.current.push(msg.score); + break; + case "progress": + setStats({ + seedsSearched: Number(msg.progress.seedsSearched), + matchingSeeds: Number(msg.progress.matchingSeeds), + seedsPerSecond: Math.round(msg.progress.seedsPerMillisecond * 1000), + percentComplete: msg.progress.percentComplete, + }); + flush(); + break; + case "done": + flush(); + setStatus(msg.cancelled ? "idle" : "done"); + break; + case "error": + setError(msg.message); + setStatus("error"); + break; + } + }; + worker.onerror = (e) => { + setError(e.message || "Worker crashed"); + setStatus("error"); + }; + + return () => { + worker.postMessage({ type: "cancel" } satisfies WorkerRequest); + worker.terminate(); + workerRef.current = null; + }; + }, [flush]); + const tallyLabels = useMemo(() => { try { return parseJaml(jamlText).should.map((clause) => clause.label); @@ -92,70 +159,69 @@ export default function JamlView() { } }, [jamlText]); - useEffect(() => { - return () => tokenRef.current?.cancel(); - }, []); - const isSearching = status === "running" || status === "booting"; - const runSearch = useCallback(async () => { + const buildScope = useCallback((): SearchScope => { + switch (scopeMode) { + case "random": + return { mode: "random", count: Math.max(1, Math.trunc(randomCount)) }; + case "sequential": + return { mode: "sequential" }; + case "keyword": + return { + mode: "keyword", + keywords: keywords.split(",").map((k) => k.trim()).filter(Boolean), + quickPad, + }; + case "aesthetic": + return { mode: "aesthetic", aesthetic, quickPad }; + case "seedList": + return { + mode: "seedList", + seeds: seedList.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean), + }; + } + }, [scopeMode, randomCount, keywords, quickPad, aesthetic, seedList]); + + const runSearch = useCallback(() => { setError(null); setResults([]); setStats(EMPTY_STATS); - setStatus("booting"); - - const token = new SearchCancellationToken(); - tokenRef.current = token; + collectedRef.current = []; - const collected: Array = []; - const onScored = (score: MotelySeedScore) => { - collected.push(score); - collected.sort((a, b) => b.score - a.score); - if (collected.length > MAX_RESULTS) collected.length = MAX_RESULTS; - setResults([...collected]); + const config: SearchConfig = { + jaml: jamlText, + scope: buildScope(), + deck, + stake, + batchCharacterCount: batchCharacterCount > 0 ? Math.trunc(batchCharacterCount) : null, + providerBatchSeedCount: + providerBatchSeedCount > 0 ? Math.trunc(providerBatchSeedCount) : null, + startBatchIndex: startBatchIndex > 0 ? Math.trunc(startBatchIndex) : null, + endBatchIndex: endBatchIndex > 0 ? Math.trunc(endBatchIndex) : null, + autoScoreCutoff, + stopAfter: Math.max(0, Math.trunc(stopAfter)), }; - const onProgress = (p: MotelyProgress) => { - setStats({ - seedsSearched: Number(p.seedsSearched), - matchingSeeds: Number(p.matchingSeeds), - seedsPerSecond: Math.round(p.seedsPerMillisecond * 1000), - percentComplete: p.percentComplete, - }); - }; - - Search.onScored.subscribe(onScored); - Search.onProgress.subscribe(onProgress); - - try { - await ensureBooted(); - setStatus("running"); - - const sampleCount = Math.max(1, Math.trunc(randomCount)); - const matchLimit = Math.max(0, Math.trunc(stopAfter)); - let settings = Search.settings(jamlText).withProgressReportIntervalMs(250n); - settings = - scope === "random" - ? settings.withRandomSearch(sampleCount) - : settings.withSequentialSearch(); - if (matchLimit > 0) settings = settings.stopAfter(BigInt(matchLimit)); - - await settings.start(token); - setStatus(token.isCancellationRequested ? "idle" : "done"); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setStatus("error"); - } finally { - Search.onScored.unsubscribe(onScored); - Search.onProgress.unsubscribe(onProgress); - tokenRef.current = null; - } - }, [jamlText, scope, randomCount, stopAfter]); + setStatus("booting"); + workerRef.current?.postMessage({ type: "start", config } satisfies WorkerRequest); + }, [ + jamlText, + buildScope, + deck, + stake, + batchCharacterCount, + providerBatchSeedCount, + startBatchIndex, + endBatchIndex, + autoScoreCutoff, + stopAfter, + ]); const handleSearch = useCallback(() => { if (isSearching) { - tokenRef.current?.cancel(); + workerRef.current?.postMessage({ type: "cancel" } satisfies WorkerRequest); } else { - void runSearch(); + runSearch(); } }, [isSearching, runSearch]); @@ -186,7 +252,31 @@ export default function JamlView() { }; const hitTone = status === "error" ? "red" : status === "done" ? "green" : "blue"; - const parseCount = (v: string) => Math.max(0, Math.trunc(Number(v.replace(/[^\d]/g, "")) || 0)); + const scopeButtons: Array<[ScopeMode, string]> = [ + ["random", "Random"], + ["sequential", "Sequential"], + ["keyword", "Keyword"], + ["aesthetic", "Aesthetic"], + ["seedList", "Seed list"], + ]; + + const numberField = ( + label: string, + value: number, + onChange: (n: number) => void, + hint?: string, + ) => ( + + {label} + onChange(parseCount(e.currentTarget.value))} + disabled={isSearching} + /> + {hint && {hint}} + + ); const sidebar = ( @@ -200,59 +290,170 @@ export default function JamlView() { {formatNumber(stats.seedsSearched)} searched · {formatNumber(stats.seedsPerSecond)}/s - {scope === "random" && isSearching ? ` · ${Math.round(stats.percentComplete)}%` : ""} + {scopeMode === "random" && isSearching + ? ` · ${Math.round(stats.percentComplete)}%` + : ""} - {(engineError || error) && ( - - {engineError ? `Engine failed to boot: ${engineError}` : error} - - )} + {error && {error}} - + - - setScope("random")} - > - Random - +
+ {scopeButtons.map(([mode, label]) => ( + setScopeMode(mode)} + > + {label} + + ))} +
+ + {scopeMode === "random" && + numberField("Seeds to sample", randomCount, (n) => + setRandomCount(Math.max(1, n)), + )} + + {scopeMode === "keyword" && ( + + Keywords (comma-separated) + setKeywords(e.currentTarget.value)} + disabled={isSearching} + /> + + )} + + {scopeMode === "aesthetic" && ( + + Aesthetic + + + )} + + {(scopeMode === "keyword" || scopeMode === "aesthetic") && ( setScope("sequential")} + onClick={() => setQuickPad((q) => !q)} > - Sequential + Quick pad: {quickPad ? "on" : "off"} -
- {scope === "random" && ( + )} + + {scopeMode === "seedList" && ( - Seeds to sample + Seeds (space/comma-separated) setRandomCount(Math.max(1, parseCount(e.currentTarget.value)))} + value={seedList} + onChange={(e) => setSeedList(e.currentTarget.value)} disabled={isSearching} /> )} + + {numberField( + "Stop after N matches (0 = unlimited)", + stopAfter, + setStopAfter, + )} +
+
+ + + - Stop after N matches (0 = unlimited) - setStopAfter(parseCount(e.currentTarget.value))} + Deck (JAML default if unset) + - - Deck & stake come from the JAML. Sequential walks the whole seed space; cancel any time. - + + Stake (JAML default if unset) + + + setAutoScoreCutoff((v) => !v)} + > + Auto score cutoff: {autoScoreCutoff ? "on" : "off"} + + + + + + + setShowAdvanced((v) => !v)} + > + {showAdvanced ? "Hide" : "Show"} batch controls + + {showAdvanced && ( + + {numberField( + "Batch character count (0 = auto)", + batchCharacterCount, + setBatchCharacterCount, + )} + {numberField( + "Provider batch seed count (0 = auto)", + providerBatchSeedCount, + setProviderBatchSeedCount, + )} + {numberField( + "Start batch index (0 = start)", + startBatchIndex, + setStartBatchIndex, + )} + {numberField( + "End batch index (0 = end)", + endBatchIndex, + setEndBatchIndex, + )} + + )} @@ -271,7 +472,7 @@ export default function JamlView() { subtitle={statusLabel[status]} />
-
+
void>(); + onCancellationRequested = { + subscribe: (h: () => void) => this.handlers.add(h), + unsubscribe: (h: () => void) => this.handlers.delete(h), + }; + cancel() { + if (this.isCancellationRequested) return; + this.isCancellationRequested = true; + for (const h of this.handlers) h(); + } +} + +export type SearchScope = + | { mode: "random"; count: number } + | { mode: "sequential" } + | { mode: "keyword"; keywords: Array; quickPad: boolean } + | { mode: "aesthetic"; aesthetic: JamlAesthetic; quickPad: boolean } + | { mode: "seedList"; seeds: Array }; + +export interface SearchConfig { + jaml: string; + scope: SearchScope; + deck: MotelyDeck | null; + stake: MotelyStake | null; + batchCharacterCount: number | null; + providerBatchSeedCount: number | null; + startBatchIndex: number | null; + endBatchIndex: number | null; + autoScoreCutoff: boolean; + stopAfter: number; +} + +export type WorkerRequest = + | { type: "start"; config: SearchConfig } + | { type: "cancel" }; + +export type WorkerResponse = + | { type: "booting" } + | { type: "running" } + | { type: "scored"; score: MotelySeedScore } + | { type: "progress"; progress: MotelyProgress } + | { type: "done"; cancelled: boolean } + | { type: "error"; message: string }; + +let bootPromise: Promise | null = null; +function ensureBooted() { + if (motely.getStatus() === motely.BootStatus.Booted) return Promise.resolve(); + if (!bootPromise) bootPromise = motely.boot(); + return bootPromise; +} + +let activeToken: SearchCancellationToken | null = null; + +const post = (msg: WorkerResponse) => (self as unknown as Worker).postMessage(msg); + +async function run(config: SearchConfig) { + const token = new SearchCancellationToken(); + activeToken = token; + + const onScored = (score: MotelySeedScore) => post({ type: "scored", score }); + const onProgress = (progress: MotelyProgress) => post({ type: "progress", progress }); + Search.onScored.subscribe(onScored); + Search.onProgress.subscribe(onProgress); + + try { + post({ type: "booting" }); + await ensureBooted(); + post({ type: "running" }); + + let s = Search.settings(config.jaml).withProgressReportIntervalMs(250n); + + switch (config.scope.mode) { + case "random": + s = s.withRandomSearch(config.scope.count); + break; + case "sequential": + s = s.withSequentialSearch(); + break; + case "keyword": + s = s.withKeywordSearch(config.scope.keywords, config.scope.quickPad); + break; + case "aesthetic": + s = s.withAestheticSearch(config.scope.aesthetic, config.scope.quickPad); + break; + case "seedList": + s = s.withSeedList(config.scope.seeds); + break; + } + + if (config.deck !== null) s = s.withDeck(config.deck); + if (config.stake !== null) s = s.withStake(config.stake); + if (config.batchCharacterCount !== null) + s = s.withBatchCharacterCount(config.batchCharacterCount); + if (config.providerBatchSeedCount !== null) + s = s.withProviderBatchSeedCount(config.providerBatchSeedCount); + if (config.startBatchIndex !== null) + s = s.withStartBatchIndex(BigInt(config.startBatchIndex)); + if (config.endBatchIndex !== null) + s = s.withEndBatchIndex(BigInt(config.endBatchIndex)); + if (config.autoScoreCutoff) s = s.withAutoScoreCutoff(true); + if (config.stopAfter > 0) s = s.stopAfter(BigInt(config.stopAfter)); + + await s.start(token as never); + post({ type: "done", cancelled: token.isCancellationRequested }); + } catch (e) { + post({ type: "error", message: e instanceof Error ? e.message : String(e) }); + } finally { + Search.onScored.unsubscribe(onScored); + Search.onProgress.unsubscribe(onProgress); + if (activeToken === token) activeToken = null; + } +} + +self.onmessage = (ev: MessageEvent) => { + const msg = ev.data; + if (msg.type === "start") void run(msg.config); + else if (msg.type === "cancel") activeToken?.cancel(); +}; diff --git a/vite.config.js b/vite.config.js index 850998d..52ce863 100644 --- a/vite.config.js +++ b/vite.config.js @@ -29,6 +29,9 @@ export default defineConfig({ exclude: ['motely-wasm'], }, assetsInclude: ['**/*.wasm'], + worker: { + format: 'es', + }, build: { target: 'esnext', },