diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..263ea0f --- /dev/null +++ b/.npmrc @@ -0,0 +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/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..8a54bad 100644 --- a/src/components/blueprint/jamlView/JamlView.tsx +++ b/src/components/blueprint/jamlView/JamlView.tsx @@ -1,93 +1,485 @@ -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { JamlIde, - JamlSpeedometer, - JamlAestheticSelector, - JamlSeedInput, - useSearch, - type JamlAestheticOption, + JimboBadge, + JimboButton, + JimboPanel, + JimboRow, + JimboStack, + JimboStatusPill, + JimboText, + JimboTextInput, + parseJaml, } 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 -`; +import { JamlAesthetic, MotelyDeck, MotelyStake } from "motely-wasm"; +import { useJamlSearch } from "../../../modules/state/jamlSearchContext.tsx"; +import type { JamlIdeSearchResult, JimboStatus } from "jaml-ui"; +import type { MotelySeedScore } from "motely-wasm"; +import type { + SearchConfig, + SearchScope, + WorkerRequest, + WorkerResponse, +} from "./searchWorker.ts"; + +type Status = "idle" | "booting" | "running" | "done" | "error"; +type ScopeMode = SearchScope["mode"]; + +interface SearchStats { + seedsSearched: number; + matchingSeeds: number; + seedsPerSecond: number; + percentComplete: number; +} + +const EMPTY_STATS: SearchStats = { + seedsSearched: 0, + matchingSeeds: 0, + seedsPerSecond: 0, + percentComplete: 0, +}; + +// 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 [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); + + // 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(""); + + // 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); + + 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); + } catch { + return []; + } + }, [jamlText]); + + const isSearching = status === "running" || status === "booting"; + + 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 isSearching = search.status === "running" || search.status === "booting"; + const runSearch = useCallback(() => { + setError(null); + setResults([]); + setStats(EMPTY_STATS); + collectedRef.current = []; - const handleSearch = () => { + 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)), + }; + 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) { - search.cancel(); - } else if (aesthetic) { - search.startAesthetic(jaml, aestheticValue); + workerRef.current?.postMessage({ type: "cancel" } satisfies WorkerRequest); } else { - search.start(jaml, 10_000); + 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 pillStatus: Record = { + idle: "idle", + booting: "running", + running: "running", + done: "ok", + error: "error", }; + const hitTone = status === "error" ? "red" : status === "done" ? "green" : "blue"; - const sidebar = ( -
- - - { setAesthetic(a); setAestheticValue(v); }} + 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 = ( + + + + + + + {formatNumber(stats.matchingSeeds || results.length)} hits + + + + {formatNumber(stats.seedsSearched)} searched · {formatNumber(stats.seedsPerSecond)}/s + {scopeMode === "random" && isSearching + ? ` · ${Math.round(stats.percentComplete)}%` + : ""} + + {error && {error}} + + + + + +
+ {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") && ( + setQuickPad((q) => !q)} + > + Quick pad: {quickPad ? "on" : "off"} + + )} + + {scopeMode === "seedList" && ( + + Seeds (space/comma-separated) + setSeedList(e.currentTarget.value)} + disabled={isSearching} + /> + + )} + + {numberField( + "Stop after N matches (0 = unlimited)", + stopAfter, + setStopAfter, + )} +
+
+ + + + + Deck (JAML default if unset) + + + + 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, + )} + + )} + + +
); return ( -
-
+
+
-
+
+ + {isSearching ? "Cancel" : "Start Search"} + {sidebar}
diff --git a/src/components/blueprint/jamlView/searchWorker.ts b/src/components/blueprint/jamlView/searchWorker.ts new file mode 100644 index 0000000..0013a36 --- /dev/null +++ b/src/components/blueprint/jamlView/searchWorker.ts @@ -0,0 +1,139 @@ +// The engine runs here, off the main thread. motely-wasm 26 is single-threaded +// and blocks whatever realm it runs in; on the UI thread that froze the tab for +// seconds at a time. In a worker the freeze is invisible — the UI stays live and +// results/progress stream back over postMessage. +// +// This drives motely-wasm's Search directly (per Blueprint's "no wrapper modules" +// rule). The worker boundary is a thread, not an abstraction: it still calls +// Search.settings(jaml).with…().start(token) with no layer in between. +import "../../../modules/uint8Base64Polyfill.ts"; +import motely, { + JamlAesthetic, + MotelyDeck, + MotelyStake, + Search, +} from "motely-wasm"; +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(); + } +} + +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/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..6e9d1c8 --- /dev/null +++ b/src/modules/uint8Base64Polyfill.ts @@ -0,0 +1,42 @@ +// 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. +// 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; +} + +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; +if (typeof statics.fromBase64 !== "function") { + statics.fromBase64 = decodeBase64; +} 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', },