From b32c38019efdb7f8e3925be21727ba66f2f3439b Mon Sep 17 00:00:00 2001 From: ncode Date: Thu, 17 Sep 2026 00:53:04 +0300 Subject: [PATCH] Phase 2: domain layer with tests Vite + React + TypeScript + Vitest. No router, state library or UI kit. The 159-row combo table is extracted from reference/index.html by scripts/extract-reference-data.mjs rather than retyped: it balances brackets past string literals to slice out the pure-data declarations and evaluates them in a vm with no DOM. raw.ts and labels.ts are generated. The extracted count matches the 159 the Phase 1 app reported on device. Entry.id keeps the reference's format verbatim (shift>p1@S#FIX VEL), built from the unsplit context string, so drill progress already on the phone migrates instead of being orphaned. The domain is pure where the reference was not: boxes go in and new boxes come out, and the random source is injected. That is what made card selection testable. Two bugs found by doing so: - pickCard returned the card just answered when random() gave exactly 0. A zero-weight card does not move the accumulator, so "r <= 0" matched it. The reference has this; at one draw in 2^53 it is invisible in use, but a pinned random source hits it every time. Fixed to "r < 0". - boxDistribution incremented through an index that noUncheckedIndexedAccess types as possibly undefined. 65 tests. Several assert numbers observed on the device: 159 entries, sample-mode layers of 3 / 35 / 2, a 16-card SHIFT+pads deck. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 42 + package-lock.json | 1561 +++++++++++++++++++++++++- package.json | 19 +- scripts/extract-reference-data.mjs | 83 ++ src/domain/drill.test.ts | 207 ++++ src/domain/drill.ts | 161 +++ src/domain/entries.test.ts | 130 +++ src/domain/entries.ts | 78 ++ src/domain/labels.ts | 186 +++ src/domain/layers.test.ts | 127 +++ src/domain/layers.ts | 81 ++ src/domain/raw.ts | 1684 ++++++++++++++++++++++++++++ src/domain/types.ts | 67 ++ tsconfig.json | 22 + vitest.config.ts | 18 + 15 files changed, 4461 insertions(+), 5 deletions(-) create mode 100644 scripts/extract-reference-data.mjs create mode 100644 src/domain/drill.test.ts create mode 100644 src/domain/drill.ts create mode 100644 src/domain/entries.test.ts create mode 100644 src/domain/entries.ts create mode 100644 src/domain/labels.ts create mode 100644 src/domain/layers.test.ts create mode 100644 src/domain/layers.ts create mode 100644 src/domain/raw.ts create mode 100644 src/domain/types.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 75b7d69..b07a2ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,48 @@ now empty and bar styling is configured in `capacitor.config.json` installs over Phase 1's without an uninstall and drill progress survives. `appId` is `dev.ncode.sp404layers` and must not change for the same reason. +## Phase 2: the React port + +Stack: **Vite + React + TypeScript + Vitest**. No router, no state library, no UI +kit — the app is four tabs and a reducer, and none of those earn their keep. + +| Command | What it does | +|---|---| +| `npm test` | Run the domain suite once | +| `npm run test:watch` | Watch mode | +| `npm run typecheck` | `tsc --noEmit` | +| `npm run extract:data` | Regenerate the combo table from `reference/` | + +``` +src/domain/ + types.ts hand-written: RawEntry, Entry, ContextCode, GroupId + raw.ts GENERATED — the 159-row combo table + labels.ts GENERATED — control labels, groups, contexts + entries.ts derives Entry[] from RAW + layers.ts which combos exist for a context + held modifiers + drill.ts decks, Leitner boxes, answer checking, card selection +``` + +**The combo table is never transcribed by hand.** `scripts/extract-reference-data.mjs` +lifts the literals out of `reference/index.html`, evaluating only pure-data +declarations in a `vm` with no DOM, and writes `raw.ts` and `labels.ts`. Re-run it +after any reference change; never edit those two files. Retyping 159 rows is +precisely where silent errors would creep in, and the extraction cross-checks: +159 entries, matching the count the Phase 1 app reported on the device. + +**`Entry.id` keeps the reference's format verbatim** (`shift>p1@S#FIX VEL`) — built +from the *unsplit* context string and the target as authored. That string is the +key drill progress is stored under, so preserving it byte-for-byte means existing +boxes on the phone migrate to the React app instead of being orphaned. + +**The domain layer is pure.** The reference mutated a module-level `store` and +called `Math.random()` inline; here boxes go in and new boxes come out, and the +random source is injected. That is what makes card selection testable at all. + +Tests assert against numbers **observed on the device**, not just against the +code: 159 entries, sample-mode layer counts of 3 / 35 / 2 for no-modifier / +SHIFT / SHIFT+REVERSE, and a 16-card SHIFT+pads deck. + ## Visual spec (settled — build Phase 2 against this) The look is **Direction A, "manual"**, after Teenage Engineering's EP-133 manual, diff --git a/package-lock.json b/package-lock.json index fab2742..839d031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,18 @@ "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.2", "@capacitor/haptics": "^8.0.2", - "@capacitor/preferences": "^8.0.1" + "@capacitor/preferences": "^8.0.1", + "react": "^19.3.0", + "react-dom": "^19.3.0" }, "devDependencies": { - "@capacitor/cli": "^8.5.2" + "@capacitor/cli": "^8.5.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.0", + "vitest": "^5.0.1" } }, "node_modules/@capacitor/android": { @@ -259,6 +267,331 @@ "node": ">=18.0.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", @@ -279,6 +612,27 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, "node_modules/@types/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", @@ -286,6 +640,414 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.9.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", @@ -322,6 +1084,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -429,6 +1201,16 @@ "node": "*" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -484,6 +1266,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -512,6 +1301,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/elementtree": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", @@ -542,6 +1341,33 @@ "node": ">=6" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -552,6 +1378,24 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "11.4.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", @@ -567,6 +1411,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -678,6 +1537,267 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -688,6 +1808,16 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -734,6 +1864,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/native-run": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", @@ -760,6 +1909,20 @@ "node": ">=16.0.0" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -819,6 +1982,27 @@ "dev": true, "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", @@ -834,6 +2018,35 @@ "node": ">=10.4.0" } }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -858,6 +2071,28 @@ "node": ">=6" } }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -893,6 +2128,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -921,6 +2190,12 @@ "dev": true, "license": "ISC" }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -957,6 +2232,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1014,6 +2296,16 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1024,6 +2316,20 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -1099,6 +2405,43 @@ "readable-stream": "3" } }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -1115,6 +2458,41 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1160,6 +2538,168 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1176,6 +2716,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 0809a17..26e2547 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "sp404-layers", "version": "1.0.0", "private": true, + "type": "module", "description": "SP-404MK2 combo trainer, wrapped as an Android app", "scripts": { "sync:www": "bash scripts/sync-www.sh", @@ -13,16 +14,28 @@ "device:sleep-normal": "bash scripts/device.sh sleep-normal", "device:launch": "bash scripts/device.sh launch", "run:android": "npm run apk && npm run install:apk && npm run device:launch", - "logcat": "adb logcat -s Capacitor:V Capacitor/Console:V chromium:V" + "logcat": "adb logcat -s Capacitor:V Capacitor/Console:V chromium:V", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "extract:data": "node scripts/extract-reference-data.mjs" }, "dependencies": { "@capacitor/android": "^8.5.2", "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.2", "@capacitor/haptics": "^8.0.2", - "@capacitor/preferences": "^8.0.1" + "@capacitor/preferences": "^8.0.1", + "react": "^19.3.0", + "react-dom": "^19.3.0" }, "devDependencies": { - "@capacitor/cli": "^8.5.2" + "@capacitor/cli": "^8.5.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.0", + "vitest": "^5.0.1" } } diff --git a/scripts/extract-reference-data.mjs b/scripts/extract-reference-data.mjs new file mode 100644 index 0000000..8298cb4 --- /dev/null +++ b/scripts/extract-reference-data.mjs @@ -0,0 +1,83 @@ +// Extracts the combo table and label maps from reference/index.html into TypeScript. +// +// The reference is the data spec, so the 159 entries are never retyped by hand: +// this lifts the literals straight out of it and re-runs whenever it changes. +// It reads only pure-data declarations, evaluated in a vm with no DOM. +import { readFileSync, writeFileSync } from "node:fs"; +import vm from "node:vm"; + +const SRC = "reference/index.html"; +const src = readFileSync(SRC, "utf8"); + +// Balance brackets from a declaration's first opener, skipping string literals so +// that braces or quotes inside descriptions cannot end the scan early. +function literalAfter(name) { + const at = src.indexOf(`const ${name}=`); + if (at < 0) throw new Error(`declaration not found: ${name}`); + let i = src.indexOf("=", at) + 1; + while (/\s/.test(src[i])) i++; + const start = i; + let depth = 0, quote = null; + for (; i < src.length; i++) { + const c = src[i]; + if (quote) { + if (c === "\\") { i++; continue; } + if (c === quote) quote = null; + continue; + } + if (c === '"' || c === "'" || c === "`") { quote = c; continue; } + if (c === "{" || c === "[" || c === "(") depth++; + else if (c === "}" || c === "]" || c === ")") { + depth--; + if (depth === 0) return src.slice(start, i + 1); + } + } + throw new Error(`unbalanced literal: ${name}`); +} + +const NAMES = ["L", "PL", "GROUPS", "GNAME", "NOLATCH", "MODORDER", "RAW", "CTX", "CTXHINT"]; +const ctx = vm.createContext({}); +for (const n of NAMES) vm.runInContext(`var ${n} = ${literalAfter(n)};`, ctx); +// The reference adds the 16 pad labels in a loop right after declaring L. +vm.runInContext(`for (let i = 1; i <= 16; i++) L["p" + i] = "PAD " + i;`, ctx); + +const g = (n) => vm.runInContext(n, ctx); +const header = (what) => + `// GENERATED from ${SRC} by scripts/extract-reference-data.mjs — do not edit by hand.\n` + + `// ${what}\n\n`; + +const j = (v) => JSON.stringify(v, null, 2); + +writeFileSync( + "src/domain/raw.ts", + header("The combo table, exactly as the reference declares it.") + + `import type { RawEntry } from "./types";\n\nexport const RAW: RawEntry[] = ${j(g("RAW"))};\n` +); + +writeFileSync( + "src/domain/labels.ts", + header("Control labels, groupings and context names.") + + `import type { ControlId, ContextCode, GroupId } from "./types";\n\n` + + `/** Full name printed on each control. */\n` + + `export const L: Record = ${j(g("L"))};\n\n` + + `/** Shorter labels used on the panel where the full one will not fit. */\n` + + `export const PL: Partial> = ${j(g("PL"))};\n\n` + + `/** A target may name a whole group of controls rather than one. */\n` + + `export const GROUPS: Record = ${j(g("GROUPS"))};\n\n` + + `export const GNAME: Record = ${j(g("GNAME"))};\n\n` + + `/** Controls that never latch when held: the knobs and the pads. */\n` + + `export const NOLATCH: ControlId[] = ${j([...g("NOLATCH")])};\n\n` + + `/** Canonical modifier order, so a layer key is stable however it was pressed. */\n` + + `export const MODORDER: ControlId[] = ${j(g("MODORDER"))};\n\n` + + `export const CTX: Record = ${j(g("CTX"))};\n\n` + + `export const CTXHINT: Record = ${j(g("CTXHINT"))};\n` +); + +const raw = g("RAW"); +console.log(`RAW entries ${raw.length}`); +console.log(`control labels ${Object.keys(g("L")).length}`); +console.log(`contexts ${Object.keys(g("CTX")).join(", ")}`); +console.log(`groups ${Object.keys(g("GROUPS")).join(", ")}`); +console.log(`entries with how ${raw.filter((r) => r.length > 7).length}`); +console.log(`group targets ${raw.filter((r) => typeof r[1] === "string" && g("GROUPS")[r[1]]).length}`); +console.log(`array targets ${raw.filter((r) => Array.isArray(r[1])).length}`); diff --git a/src/domain/drill.test.ts b/src/domain/drill.test.ts new file mode 100644 index 0000000..d234e09 --- /dev/null +++ b/src/domain/drill.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { + DECKS, + boxDistribution, + boxOf, + deckPool, + gradeBoxes, + isCorrect, + isPressable, + knownCount, + nextBox, + pickCard, + pickWeight, +} from "./drill"; +import { ENTRIES } from "./entries"; +import type { Boxes } from "./drill"; +import type { Entry } from "./types"; + +const byName = (name: string): Entry => { + const found = ENTRIES.find((e) => e.name === name); + if (!found) throw new Error(`no entry named ${name}`); + return found; +}; + +const fixedVelocity = byName("Fixed velocity"); +const padMute = byName("Pad mute"); // shift + reverse, targets all 16 pads + +describe("decks", () => { + it("puts all sixteen SHIFT + pad combos in the pads deck", () => { + // The device showed "0 of 16 known" for this deck. + expect(deckPool(ENTRIES, "shiftpads", "play")).toHaveLength(16); + }); + + it("keeps the two decks disjoint", () => { + const pads = new Set(deckPool(ENTRIES, "shiftpads", "name").map((e) => e.id)); + const buttons = deckPool(ENTRIES, "shiftbtn", "name"); + expect(buttons.some((e) => pads.has(e.id))).toBe(false); + }); + + it("includes everything in the everything deck", () => { + expect(deckPool(ENTRIES, "all", "name")).toHaveLength(ENTRIES.length); + }); + + it("names every deck", () => { + for (const deck of Object.values(DECKS)) expect(deck.name).not.toBe(""); + }); +}); + +describe("isPressable", () => { + it("rejects combos on the CTRL knobs, which are turned not pressed", () => { + const knobs = ENTRIES.filter((e) => e.target === "ctrls"); + expect(knobs.length).toBeGreaterThan(0); + for (const e of knobs) expect(isPressable(e)).toBe(false); + }); + + it("rejects gestures the table describes in prose", () => { + expect(isPressable(byName("Panic stop"))).toBe(false); // "Tap EXIT four times fast" + expect(isPressable(byName("Fine CTRL 1"))).toBe(false); // "Hold SHIFT, turn CTRL 1" + }); + + it("rejects unmodified combos that light a whole group", () => { + const groupy = ENTRIES.filter((e) => e.mods.length === 0 && e.tset.length > 1); + for (const e of groupy) expect(isPressable(e)).toBe(false); + }); + + it("accepts an ordinary two-control combo", () => { + expect(isPressable(fixedVelocity)).toBe(true); + }); + + it("only narrows the pool when asking play-first", () => { + const play = deckPool(ENTRIES, "all", "play").length; + const name = deckPool(ENTRIES, "all", "name").length; + expect(play).toBeLessThan(name); + }); +}); + +describe("isCorrect", () => { + it("accepts the modifier plus the right target", () => { + expect(isCorrect(fixedVelocity, ["shift", "p1"])).toBe(true); + }); + + it("does not care about the order fingers went down", () => { + expect(isCorrect(fixedVelocity, ["p1", "shift"])).toBe(true); + }); + + it("rejects a missing modifier", () => { + expect(isCorrect(fixedVelocity, ["p1"])).toBe(false); + }); + + it("rejects the wrong target", () => { + expect(isCorrect(fixedVelocity, ["shift", "p2"])).toBe(false); + }); + + it("rejects extra controls beyond the one target", () => { + expect(isCorrect(fixedVelocity, ["shift", "p1", "p2"])).toBe(false); + }); + + it("rejects the modifiers alone", () => { + expect(isCorrect(fixedVelocity, ["shift"])).toBe(false); + }); + + it("accepts any control in a group target", () => { + expect(isCorrect(padMute, ["shift", "reverse", "p5"])).toBe(true); + expect(isCorrect(padMute, ["shift", "reverse", "p16"])).toBe(true); + expect(isCorrect(padMute, ["shift", "reverse", "exit"])).toBe(false); + }); + + it("rejects an empty chord", () => { + expect(isCorrect(fixedVelocity, [])).toBe(false); + }); +}); + +describe("boxes", () => { + it("treats a card never seen as box 1", () => { + expect(boxOf({}, fixedVelocity)).toBe(1); + }); + + it("promotes one box when right and resets to 1 when wrong", () => { + expect(nextBox(1, true)).toBe(2); + expect(nextBox(4, true)).toBe(5); + expect(nextBox(5, true)).toBe(5); + expect(nextBox(4, false)).toBe(1); + expect(nextBox(1, false)).toBe(1); + }); + + it("returns new boxes rather than mutating the ones given", () => { + const before: Boxes = { [fixedVelocity.id]: 2 }; + const after = gradeBoxes(before, fixedVelocity, true); + expect(after[fixedVelocity.id]).toBe(3); + expect(before[fixedVelocity.id]).toBe(2); + expect(after).not.toBe(before); + }); + + it("clamps stored values that are out of range", () => { + expect(boxOf({ [fixedVelocity.id]: 99 }, fixedVelocity)).toBe(5); + expect(boxOf({ [fixedVelocity.id]: 0 }, fixedVelocity)).toBe(1); + expect(boxOf({ [fixedVelocity.id]: -3 }, fixedVelocity)).toBe(1); + }); +}); + +describe("choosing the next card", () => { + const pool = deckPool(ENTRIES, "shiftpads", "play"); + + it("weighs a new card sixteen times a known one", () => { + const known: Boxes = { [pool[0]!.id]: 5 }; + expect(pickWeight(pool[0]!, known, null, pool.length)).toBe(1); + expect(pickWeight(pool[1]!, {}, null, pool.length)).toBe(16); + }); + + it("never picks the card just answered", () => { + const last = pool[0]!; + // Lowest possible draw would otherwise land on the first card. + expect(pickCard(pool, {}, last, () => 0)).not.toBe(last); + }); + + it("does repeat when the deck holds a single card", () => { + const only = [pool[0]!]; + expect(pickCard(only, {}, only[0]!, () => 0)).toBe(only[0]); + }); + + it("is deterministic given a fixed random source", () => { + expect(pickCard(pool, {}, null, () => 0)).toBe(pool[0]); + expect(pickCard(pool, {}, null, () => 0.999999)).toBe(pool[pool.length - 1]); + }); + + it("favours the box a card sits in", () => { + // Two cards: the first known, the second new. Weights are 1 and 16, so a + // draw just past the first card's share must land on the second. + const two = [pool[0]!, pool[1]!]; + const boxes: Boxes = { [two[0]!.id]: 5 }; + expect(pickCard(two, boxes, null, () => 0)).toBe(two[0]); + expect(pickCard(two, boxes, null, () => 0.5)).toBe(two[1]); + }); + + it("returns nothing for an empty deck", () => { + expect(pickCard([], {}, null, () => 0)).toBeNull(); + }); +}); + +describe("progress", () => { + const pool = deckPool(ENTRIES, "shiftpads", "play"); + + it("starts with everything in box 1", () => { + expect(boxDistribution(pool, {})).toEqual([16, 0, 0, 0, 0]); + expect(knownCount(pool, {})).toBe(0); + }); + + it("always sums to the size of the deck", () => { + const boxes: Boxes = { [pool[0]!.id]: 5, [pool[1]!.id]: 3 }; + const dist = boxDistribution(pool, boxes); + expect(dist.reduce((a, b) => a + b, 0)).toBe(pool.length); + }); + + it("counts only the top box as known", () => { + const boxes: Boxes = { [pool[0]!.id]: 5, [pool[1]!.id]: 4 }; + expect(knownCount(pool, boxes)).toBe(1); + }); + + it("moves a card up a box at a time as it is answered", () => { + let boxes: Boxes = {}; + for (let i = 0; i < 4; i++) boxes = gradeBoxes(boxes, pool[0]!, true); + expect(knownCount(pool, boxes)).toBe(1); + boxes = gradeBoxes(boxes, pool[0]!, false); + expect(knownCount(pool, boxes)).toBe(0); + expect(boxOf(boxes, pool[0]!)).toBe(1); + }); +}); diff --git a/src/domain/drill.ts b/src/domain/drill.ts new file mode 100644 index 0000000..9a2bf2d --- /dev/null +++ b/src/domain/drill.ts @@ -0,0 +1,161 @@ +/** + * Leitner drill: boxes 1 (new or just missed) to 5 (known). Right promotes one + * box, wrong resets to 1, and selection weight halves per box. + * + * All pure: boxes in, new boxes out, random injected. Angular: no stateful + * service — these are a reducer's helpers. + */ +import type { ControlId, Entry } from "./types"; + +export type DeckId = + | "shiftpads" + | "shiftbtn" + | "mods" + | "pattern" + | "dj" + | "edit" + | "all"; + +/** Which way round a card is asked. */ +export type Direction = "play" | "name"; + +export type Box = 1 | 2 | 3 | 4 | 5; + +/** Card id to box. Absent means box 1. */ +export type Boxes = Readonly>; + +export interface Deck { + readonly name: string; + readonly includes: (entry: Entry) => boolean; +} + +const isSinglePad = (entry: Entry): boolean => /^p\d+$/.test(String(entry.target)); + +export const DECKS: Readonly> = { + shiftpads: { + name: "SHIFT + pads", + includes: (e) => e.key === "shift" && isSinglePad(e) && e.ctx.includes("S"), + }, + shiftbtn: { + name: "SHIFT + buttons", + includes: (e) => e.key.startsWith("shift") && !isSinglePad(e) && e.ctx.includes("S"), + }, + mods: { + name: "REMAIN, VALUE, DEL, COPY, MFX", + includes: (e) => + /^(remain|value|del|copy|mfx)/.test(e.key) && + e.ctx.some((c) => c === "S" || c === "E"), + }, + pattern: { + name: "Pattern + TR-REC", + includes: (e) => e.ctx.some((c) => c === "P" || c === "T"), + }, + dj: { name: "DJ mode", includes: (e) => e.ctx.includes("D") }, + edit: { + name: "Edit, looper, chromatic", + includes: (e) => e.ctx.some((c) => c === "E" || c === "L" || c === "X"), + }, + all: { name: "Everything", includes: () => true }, +}; + +/** + * Can this be answered by pressing it? Not the CTRL knobs (turned), not gestures + * described in prose (a turn, four taps), not unmodified whole-group targets + * where no single control is right. Those are asked name-first only. + */ +export function isPressable(entry: Entry): boolean { + if (entry.target === "ctrls") return false; + if (entry.how && /turn|four|tap pads/i.test(entry.how)) return false; + if (entry.mods.length === 0 && entry.tset.length > 1) return false; + return true; +} + +export function deckPool( + entries: readonly Entry[], + deck: DeckId, + direction: Direction, +): Entry[] { + const inDeck = entries.filter(DECKS[deck].includes); + return direction === "name" ? inDeck : inDeck.filter(isPressable); +} + +/** Unseen is box 1. Out-of-range stored values are clamped, not trusted. */ +export function boxOf(boxes: Boxes, entry: Entry): Box { + const box = boxes[entry.id] ?? 1; + if (box <= 1) return 1; + if (box >= 5) return 5; + return Math.round(box) as Box; +} + +export function nextBox(box: Box, correct: boolean): Box { + if (!correct) return 1; + return Math.min(5, box + 1) as Box; +} + +/** Returns new boxes; never mutates the ones given. */ +export function gradeBoxes(boxes: Boxes, entry: Entry, correct: boolean): Boxes { + return { ...boxes, [entry.id]: nextBox(boxOf(boxes, entry), correct) }; +} + +/** + * Every modifier held, plus exactly one other control that the entry lights up. + * Order is irrelevant: the chord is what was down when the last finger lifted. + */ +export function isCorrect(entry: Entry, chord: readonly ControlId[]): boolean { + const held = new Set(chord); + if (!entry.mods.every((m) => held.has(m))) return false; + const rest = [...held].filter((c) => !entry.mods.includes(c)); + return rest.length === 1 && entry.tset.includes(rest[0]!); +} + +/** Box 1 weighs 16, box 5 weighs 1. The card just answered weighs nothing. */ +export function pickWeight( + entry: Entry, + boxes: Boxes, + last: Entry | null, + poolSize: number, +): number { + if (entry === last && poolSize > 1) return 0; + return 2 ** (5 - boxOf(boxes, entry)); +} + +/** Weighted random. `random` is injected so tests can pin it; returns [0, 1). */ +export function pickCard( + pool: readonly Entry[], + boxes: Boxes, + last: Entry | null = null, + random: () => number = Math.random, +): Entry | null { + if (pool.length === 0) return null; + + const weights = pool.map((e) => pickWeight(e, boxes, last, pool.length)); + const total = weights.reduce((a, b) => a + b, 0); + if (total === 0) return pool[pool.length - 1]!; + + let r = random() * total; + for (let i = 0; i < pool.length; i++) { + r -= weights[i]!; + // `< 0`, not `<= 0`: a zero-weight card doesn't move `r`, so `<= 0` returns + // the card just answered when random() gives exactly 0. The reference does. + if (r < 0) return pool[i]!; + } + return pool[pool.length - 1]!; +} + +/** Cards per box, for the progress bars. */ +export function boxDistribution( + pool: readonly Entry[], + boxes: Boxes, +): [number, number, number, number, number] { + const counts: [number, number, number, number, number] = [0, 0, 0, 0, 0]; + for (const entry of pool) { + const slot = boxOf(boxes, entry) - 1; + counts[slot] = (counts[slot] ?? 0) + 1; + } + return counts; +} + +/** Cards in the top box — what "12 of 40 known" counts. */ +export function knownCount(pool: readonly Entry[], boxes: Boxes): number { + return boxDistribution(pool, boxes)[4]; +} diff --git a/src/domain/entries.test.ts b/src/domain/entries.test.ts new file mode 100644 index 0000000..301853a --- /dev/null +++ b/src/domain/entries.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { ENTRIES, deriveEntry, expandTarget, layerKeyOf, sortMods } from "./entries"; +import { GROUPS } from "./labels"; +import { RAW } from "./raw"; +import type { RawEntry } from "./types"; + +/** + * These assert against the real table rather than fixtures. Several expected + * numbers were read off the running Phase 1 app on the device, so they check the + * port against observed behaviour, not just against itself. + */ + +describe("the table", () => { + it("has every row the reference has", () => { + expect(RAW).toHaveLength(159); + expect(ENTRIES).toHaveLength(159); + }); + + it("gives every entry a unique id", () => { + const ids = new Set(ENTRIES.map((e) => e.id)); + expect(ids.size).toBe(ENTRIES.length); + }); + + it("only uses known context codes", () => { + const known = new Set(["S", "E", "P", "T", "L", "D", "X"]); + for (const e of ENTRIES) { + expect(e.ctx.length).toBeGreaterThan(0); + for (const c of e.ctx) expect(known).toContain(c); + } + }); + + it("expands every target to at least one real control", () => { + for (const e of ENTRIES) expect(e.tset.length).toBeGreaterThan(0); + }); +}); + +describe("sortMods", () => { + it("puts modifiers in canonical order however they were pressed", () => { + expect(sortMods(["reverse", "shift"])).toEqual(["shift", "reverse"]); + expect(sortMods(["shift", "reverse"])).toEqual(["shift", "reverse"]); + }); + + it("does not mutate its argument", () => { + const held = ["reverse", "shift"]; + sortMods(held); + expect(held).toEqual(["reverse", "shift"]); + }); + + it("makes the layer key independent of press order", () => { + expect(layerKeyOf(["reverse", "shift"])).toBe("shift+reverse"); + expect(layerKeyOf(["shift", "reverse"])).toBe("shift+reverse"); + }); + + it("treats no modifiers as the empty key, which is a real layer", () => { + expect(layerKeyOf([])).toBe(""); + }); +}); + +describe("expandTarget", () => { + it("leaves a single control alone", () => { + expect(expandTarget("p1")).toEqual(["p1"]); + }); + + it("expands a group name to its controls", () => { + expect(expandTarget("pads")).toEqual(GROUPS.pads); + expect(expandTarget("pads")).toHaveLength(16); + expect(expandTarget("banks")).toHaveLength(5); + }); + + it("keeps an explicit list as given", () => { + expect(expandTarget(["p13", "p15"])).toEqual(["p13", "p15"]); + }); + + it("returns a copy, so callers cannot corrupt GROUPS", () => { + const expanded = expandTarget("pads"); + expanded.push("nonsense"); + expect(GROUPS.pads).toHaveLength(16); + }); +}); + +describe("a derived entry", () => { + const fixedVelocity = ENTRIES.find((e) => e.name === "Fixed velocity"); + + it("carries the reference's fields through unchanged", () => { + expect(fixedVelocity).toBeDefined(); + expect(fixedVelocity!.mods).toEqual(["shift"]); + expect(fixedVelocity!.key).toBe("shift"); + expect(fixedVelocity!.tset).toEqual(["p1"]); + expect(fixedVelocity!.short).toBe("FIX VEL"); + expect(fixedVelocity!.page).toBe(24); + expect(fixedVelocity!.ctx).toEqual(["S"]); + }); + + it("uses the stored-progress id format verbatim", () => { + // Drill boxes on the device are keyed by this exact string, e.g. + // "shift>p6@S#INIT" was read out of CapacitorStorage.xml during Phase 1. + expect(fixedVelocity!.id).toBe("shift>p1@S#FIX VEL"); + + const init = ENTRIES.find((e) => e.name === "Init sample params"); + expect(init!.id).toBe("shift>p6@S#INIT"); + }); + + it("builds the id from the unsplit context string", () => { + const stop = ENTRIES.find((e) => e.short === "STOP ×4"); + expect(stop!.ctx).toEqual(["S", "P", "D", "L"]); + expect(stop!.id).toBe(">exit@S P D L#STOP ×4"); + }); + + it("joins an explicit multi-control target with a slash in the id", () => { + const reset = ENTRIES.find((e) => e.short === "CH1 RST"); + expect(reset!.tset).toEqual(["p2", "p6"]); + expect(reset!.id).toBe("del>p2/p6@D#CH1 RST"); + }); + + it("omits how entirely when the reference gave none", () => { + const withHow = ENTRIES.find((e) => e.short === "SAVE FX"); + expect(withHow!.how).toBe("Hold SHIFT, hold MARK 3 s"); + expect("how" in fixedVelocity!).toBe(false); + }); + + it("sorts an entry's own modifiers", () => { + const entry = deriveEntry( + [["reverse", "shift"], "pads", "S P", "MUTE", "Pad mute", "", 26] as RawEntry, + 0, + ); + expect(entry.mods).toEqual(["shift", "reverse"]); + expect(entry.key).toBe("shift+reverse"); + expect(entry.tset).toHaveLength(16); + }); +}); diff --git a/src/domain/entries.ts b/src/domain/entries.ts new file mode 100644 index 0000000..388abeb --- /dev/null +++ b/src/domain/entries.ts @@ -0,0 +1,78 @@ +/** + * Derives `Entry[]` from the raw table, mirroring the reference. + * The `id` format is load-bearing: it keys stored drill progress. + */ +import { GROUPS, MODORDER } from "./labels"; +import { RAW } from "./raw"; +import type { ContextCode, ControlId, Entry, GroupId, RawEntry } from "./types"; + +const GROUP_IDS: ReadonlySet = new Set(Object.keys(GROUPS)); + +const isGroupId = (value: string): value is GroupId => GROUP_IDS.has(value); + +/** + * Sorts modifiers into the canonical order. + * + * Holding SHIFT then REVERSE must produce the same layer as REVERSE then SHIFT, + * so every place that builds a layer key sorts first. Controls missing from + * MODORDER get `indexOf` of -1 and sort to the front, which is the reference's + * behaviour and is relied upon by nothing — no entry uses such a modifier. + */ +export function sortMods(mods: readonly ControlId[]): ControlId[] { + return [...mods].sort((a, b) => MODORDER.indexOf(a) - MODORDER.indexOf(b)); +} + +/** + * The identity of a layer: sorted modifiers joined with `+`. + * The empty string is the no-modifier layer, which is a real layer with entries + * on it, not an absence. + */ +export function layerKeyOf(mods: readonly ControlId[]): string { + return sortMods(mods).join("+"); +} + +/** + * Expands a target into the concrete controls it lights up. + * + * A target is one control (`"p1"`), the name of a group (`"pads"`, meaning all + * sixteen), or an explicit list (`["p13", "p15"]`). + */ +export function expandTarget(target: RawEntry[1]): ControlId[] { + if (Array.isArray(target)) return [...target]; + return isGroupId(target) ? [...GROUPS[target]] : [target]; +} + +export function deriveEntry(raw: RawEntry, index: number): Entry { + const [mods, target, ctx, short, name, desc, page, how] = raw; + const sorted = sortMods(mods); + const key = sorted.join("+"); + + // Built from the *unsplit* ctx string and the target as authored, exactly as + // the reference does. Preserved verbatim so stored progress keeps matching. + const id = `${key}>${[target].flat().join("/")}@${ctx}#${short}`; + + const entry = { + index, + mods: sorted, + key, + target, + tset: expandTarget(target), + ctx: ctx.split(" ") as ContextCode[], + short, + name, + desc, + page, + id, + }; + + // `exactOptionalPropertyTypes` means an absent `how` must be absent, not + // explicitly undefined. + return how === undefined ? entry : { ...entry, how }; +} + +export function deriveEntries(raw: readonly RawEntry[] = RAW): Entry[] { + return raw.map(deriveEntry); +} + +/** Every combo in the table, derived once. */ +export const ENTRIES: readonly Entry[] = deriveEntries(); diff --git a/src/domain/labels.ts b/src/domain/labels.ts new file mode 100644 index 0000000..d00fee3 --- /dev/null +++ b/src/domain/labels.ts @@ -0,0 +1,186 @@ +// GENERATED from reference/index.html by scripts/extract-reference-data.mjs — do not edit by hand. +// Control labels, groupings and context names. + +import type { ControlId, ContextCode, GroupId } from "./types"; + +/** Full name printed on each control. */ +export const L: Record = { + "vol": "VOLUME", + "c1": "CTRL 1", + "c2": "CTRL 2", + "c3": "CTRL 3", + "fx_fd": "FILTER +DRIVE", + "fx_res": "RESONATOR", + "fx_dly": "DELAY", + "fx_iso": "ISOLATOR", + "fx_djl": "DJFX LOOPER", + "mfx": "MFX", + "pat_sel": "PATTERN SELECT", + "pat_edit": "PATTERN EDIT", + "rec_set": "RECORD SETTING", + "del": "DEL", + "rec": "REC", + "resample": "RESAMPLE", + "exit": "EXIT", + "copy": "COPY", + "remain": "REMAIN", + "start_end": "START/END", + "pitch": "PITCH/SPEED", + "mark": "MARK", + "value": "VALUE", + "bpm_sync": "BPM SYNC", + "gate": "GATE", + "loop": "LOOP", + "reverse": "REVERSE", + "roll": "ROLL", + "bank_a": "A/F", + "bank_b": "B/G", + "bank_c": "C/H", + "bank_d": "D/I", + "bank_e": "E/J", + "shift": "SHIFT", + "busfx": "BUS FX", + "hold": "HOLD", + "ext": "EXT SOURCE", + "subpad": "SUB PAD", + "p1": "PAD 1", + "p2": "PAD 2", + "p3": "PAD 3", + "p4": "PAD 4", + "p5": "PAD 5", + "p6": "PAD 6", + "p7": "PAD 7", + "p8": "PAD 8", + "p9": "PAD 9", + "p10": "PAD 10", + "p11": "PAD 11", + "p12": "PAD 12", + "p13": "PAD 13", + "p14": "PAD 14", + "p15": "PAD 15", + "p16": "PAD 16" +}; + +/** Shorter labels used on the panel where the full one will not fit. */ +export const PL: Partial> = { + "pat_sel": "PTN SEL", + "pat_edit": "PTN EDIT", + "rec_set": "REC SET", + "resample": "RE­SAMPLE", + "reverse": "REV­ERSE", + "remain": "RE­MAIN", + "start_end": "START /END", + "pitch": "PITCH /SPEED" +}; + +/** A target may name a whole group of controls rather than one. */ +export const GROUPS: Record = { + "pads": [ + "p1", + "p2", + "p3", + "p4", + "p5", + "p6", + "p7", + "p8", + "p9", + "p10", + "p11", + "p12", + "p13", + "p14", + "p15", + "p16" + ], + "ctrls": [ + "c1", + "c2", + "c3" + ], + "effects": [ + "fx_fd", + "fx_res", + "fx_dly", + "fx_iso", + "fx_djl", + "mfx" + ], + "banks": [ + "bank_a", + "bank_b", + "bank_c", + "bank_d", + "bank_e" + ] +}; + +export const GNAME: Record = { + "pads": "PAD 1–16", + "ctrls": "CTRL 1–3", + "effects": "FX button", + "banks": "BANK A–E" +}; + +/** Controls that never latch when held: the knobs and the pads. */ +export const NOLATCH: ControlId[] = [ + "vol", + "c1", + "c2", + "c3", + "p1", + "p2", + "p3", + "p4", + "p5", + "p6", + "p7", + "p8", + "p9", + "p10", + "p11", + "p12", + "p13", + "p14", + "p15", + "p16" +]; + +/** Canonical modifier order, so a layer key is stable however it was pressed. */ +export const MODORDER: ControlId[] = [ + "shift", + "reverse", + "remain", + "value", + "del", + "copy", + "mfx", + "roll", + "hold", + "subpad", + "pat_edit", + "mark", + "pitch", + "resample", + "bank_d" +]; + +export const CTX: Record = { + "S": "Sample mode", + "E": "Edit screens", + "P": "Pattern", + "T": "TR-REC", + "L": "Looper", + "D": "DJ mode", + "X": "Chromatic / synth" +}; + +export const CTXHINT: Record = { + "S": "Top screen, pads orange.", + "E": "START/END, marker, PITCH/SPEED and envelope screens.", + "P": "PATTERN SELECT lit, pads purple. Includes real-time recording and the chain editor.", + "T": "Step recording (REC in pattern mode, REMAIN until it blinks).", + "L": "MARK function set to Looper, then MARK.", + "D": "Press D/I and E/J together.", + "X": "SHIFT + pad 4, or SHIFT + RECORD SETTING." +}; diff --git a/src/domain/layers.test.ts b/src/domain/layers.test.ts new file mode 100644 index 0000000..2bd4370 --- /dev/null +++ b/src/domain/layers.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { ENTRIES } from "./entries"; +import { + contextsPresent, + entriesByTarget, + entriesForHeld, + entriesForLayer, + layerCounts, + orderLayerKeys, +} from "./layers"; + +/** + * The counts below were read off the running Phase 1 app on the phone — the + * layer picker showed "No modifier 3", "SHIFT 35", "SHIFT + REVERSE 2" in sample + * mode. If the port changes them, it has changed behaviour. + */ + +describe("entriesForLayer", () => { + it("matches the counts the device shows in sample mode", () => { + expect(entriesForLayer(ENTRIES, "S", "")).toHaveLength(3); + expect(entriesForLayer(ENTRIES, "S", "shift")).toHaveLength(35); + expect(entriesForLayer(ENTRIES, "S", "shift+reverse")).toHaveLength(2); + }); + + it("finds the three functions on the bare sample-mode layer", () => { + const shorts = entriesForLayer(ENTRIES, "S", "").map((e) => e.short).sort(); + expect(shorts).toEqual(["INFO", "SKIPBACK", "STOP ×4"]); + }); + + it("gives a different set for the same layer in another context", () => { + const inSample = entriesForLayer(ENTRIES, "S", ""); + const inPattern = entriesForLayer(ENTRIES, "P", ""); + expect(inPattern).not.toHaveLength(inSample.length); + expect(inPattern.length).toBeGreaterThan(0); + }); + + it("returns nothing for a layer that carries no functions", () => { + expect(entriesForLayer(ENTRIES, "S", "mfx+roll")).toEqual([]); + }); +}); + +describe("entriesForHeld", () => { + it("does not care what order the modifiers went down in", () => { + const one = entriesForHeld(ENTRIES, "S", ["shift", "reverse"]); + const other = entriesForHeld(ENTRIES, "S", ["reverse", "shift"]); + expect(one).toHaveLength(2); + expect(other).toEqual(one); + }); + + it("treats holding nothing as the no-modifier layer", () => { + expect(entriesForHeld(ENTRIES, "S", [])).toHaveLength(3); + }); +}); + +describe("layerCounts", () => { + const counts = layerCounts(ENTRIES, "S"); + + it("counts each layer in a context", () => { + expect(counts.get("")).toBe(3); + expect(counts.get("shift")).toBe(35); + expect(counts.get("shift+reverse")).toBe(2); + }); + + it("sums to the number of entries in that context", () => { + const total = [...counts.values()].reduce((a, b) => a + b, 0); + const inContext = ENTRIES.filter((e) => e.ctx.includes("S")).length; + expect(total).toBe(inContext); + }); + + it("omits layers belonging to other contexts", () => { + expect(layerCounts(ENTRIES, "S").has("pat_edit")).toBe(false); + expect(layerCounts(ENTRIES, "T").has("pat_edit")).toBe(true); + }); +}); + +describe("orderLayerKeys", () => { + it("puts the no-modifier layer first however the input was ordered", () => { + expect(orderLayerKeys(["shift", "", "remain"])[0]).toBe(""); + }); + + it("orders by MODORDER, then puts shorter combinations first", () => { + expect(orderLayerKeys(["shift+reverse", "shift"])).toEqual([ + "shift", + "shift+reverse", + ]); + // shift precedes remain in MODORDER + expect(orderLayerKeys(["remain", "shift"])).toEqual(["shift", "remain"]); + }); + + it("produces the order the picker showed on the device", () => { + const keys = orderLayerKeys(layerCounts(ENTRIES, "S").keys()); + expect(keys.slice(0, 3)).toEqual(["", "shift", "shift+reverse"]); + }); +}); + +describe("entriesByTarget", () => { + it("maps each lit control to the entries on it", () => { + const byTarget = entriesByTarget(entriesForLayer(ENTRIES, "S", "")); + expect([...byTarget.keys()].sort()).toEqual(["exit", "mark", "remain"]); + expect(byTarget.get("exit")!.map((e) => e.short)).toEqual(["STOP ×4"]); + }); + + it("spreads a group target across every control in the group", () => { + const byTarget = entriesByTarget(entriesForLayer(ENTRIES, "S", "shift+reverse")); + expect(byTarget.get("p1")).toHaveLength(1); + expect(byTarget.get("p16")).toHaveLength(1); + }); + + it("collects several entries when a control carries more than one", () => { + const stacked = [...entriesByTarget(ENTRIES).values()].filter((l) => l.length > 1); + expect(stacked.length).toBeGreaterThan(0); + }); +}); + +describe("contextsPresent", () => { + it("finds every context the table uses", () => { + expect([...contextsPresent(ENTRIES)].sort()).toEqual([ + "D", + "E", + "L", + "P", + "S", + "T", + "X", + ]); + }); +}); diff --git a/src/domain/layers.ts b/src/domain/layers.ts new file mode 100644 index 0000000..cddacd7 --- /dev/null +++ b/src/domain/layers.ts @@ -0,0 +1,81 @@ +/** + * Which combos exist for a context and a set of held modifiers — the heart of + * Explore. Same modifiers, different context, different set. + * + * Angular: no pipes or computed signals here. Derived data is a plain function + * call during render; memoising it is an explicit `useMemo`, not automatic. + */ +import { layerKeyOf } from "./entries"; +import { MODORDER } from "./labels"; +import type { ContextCode, ControlId, Entry } from "./types"; + +/** Entries on one layer of one context. */ +export function entriesForLayer( + entries: readonly Entry[], + ctx: ContextCode, + key: string, +): Entry[] { + return entries.filter((e) => e.ctx.includes(ctx) && e.key === key); +} + +/** Entries on the layer currently being held. */ +export function entriesForHeld( + entries: readonly Entry[], + ctx: ContextCode, + held: readonly ControlId[], +): Entry[] { + return entriesForLayer(entries, ctx, layerKeyOf(held)); +} + +/** How many functions sit on each layer of a context. */ +export function layerCounts( + entries: readonly Entry[], + ctx: ContextCode, +): Map { + const counts = new Map(); + for (const e of entries) { + if (!e.ctx.includes(ctx)) continue; + counts.set(e.key, (counts.get(e.key) ?? 0) + 1); + } + return counts; +} + +/** + * Layer keys in the order the picker lists them: no-modifier first, then by the + * first modifier's position in MODORDER, then shortest combination first — so + * SHIFT comes before SHIFT + REVERSE. + */ +export function orderLayerKeys(keys: Iterable): string[] { + return [...keys].sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + const byModifier = + MODORDER.indexOf(a.split("+")[0]!) - MODORDER.indexOf(b.split("+")[0]!); + return byModifier || a.length - b.length; + }); +} + +/** + * Which entries light up each control on the current layer. + * + * A control can carry more than one function — the panel shows a count badge and + * tapping cycles through them — so this maps to a list, not a single entry. + */ +export function entriesByTarget(entries: readonly Entry[]): Map { + const byTarget = new Map(); + for (const e of entries) { + for (const control of e.tset) { + const list = byTarget.get(control); + if (list) list.push(e); + else byTarget.set(control, [e]); + } + } + return byTarget; +} + +/** Every context an entry list touches, for building the mode picker. */ +export function contextsPresent(entries: readonly Entry[]): Set { + const present = new Set(); + for (const e of entries) for (const c of e.ctx) present.add(c); + return present; +} diff --git a/src/domain/raw.ts b/src/domain/raw.ts new file mode 100644 index 0000000..92c53e6 --- /dev/null +++ b/src/domain/raw.ts @@ -0,0 +1,1684 @@ +// GENERATED from reference/index.html by scripts/extract-reference-data.mjs — do not edit by hand. +// The combo table, exactly as the reference declares it. + +import type { RawEntry } from "./types"; + +export const RAW: RawEntry[] = [ + [ + [ + "shift" + ], + "p1", + "S", + "FIX VEL", + "Fixed velocity", + "Every hit plays at 127. Stored per sample; same combo turns it off.", + 24 + ], + [ + [ + "shift" + ], + "p2", + "S", + "16 VEL", + "16 velocity", + "Pads become 16 velocity steps of the selected sample. EXIT to leave.", + 24 + ], + [ + [ + "shift" + ], + "p3", + "S", + "CUE", + "Cue mode", + "Send a sample to headphones only. CTRL 3 balances cue against the main mix. EXIT to leave.", + 101 + ], + [ + [ + "shift" + ], + "p4", + "S", + "CHROMA", + "Chromatic mode", + "Play the selected sample across the pads in pitch. EXIT to leave.", + 25 + ], + [ + [ + "shift" + ], + "p5", + "S P", + "SWAP", + "Exchange pads", + "Swap two samples (or two patterns in pattern mode). Bank buttons reach other banks. Confirm with VALUE or COPY.", + 60 + ], + [ + [ + "shift" + ], + "p6", + "S", + "INIT", + "Init sample params", + "Resets start/end, tempo, pitch and the rest for the selected pad.", + 59 + ], + [ + [ + "shift" + ], + "p7", + "S", + "LINK", + "Pad link groups", + "Groups A–J of up to 4 pads that fire together from any member.", + 26 + ], + [ + [ + "shift" + ], + "p8", + "S", + "MUTE GRP", + "Mute groups", + "Choke groups A–J: only the last-hit sample in a group sounds.", + 28 + ], + [ + [ + "shift" + ], + "p9", + "S P L", + "METRO", + "Metronome on/off", + "Also works while sampling and recording patterns.", + 38 + ], + [ + [ + "shift" + ], + "p10", + "S P", + "COUNT-IN", + "Count-in", + "Cycles 1 bar, 2 bars, WAIT (starts on input level), off.", + 38 + ], + [ + [ + "shift" + ], + "p11", + "S", + "TEMPO", + "Tempo select / tap tempo", + "TEMPO SEL screen: CTRL 1 picks project or bank tempo, VALUE sets it (SHIFT+VALUE for decimals). SUB PAD blinks: tap it for tap tempo.", + 91 + ], + [ + [ + "shift" + ], + "p12", + "S", + "GAIN", + "Input gain", + "Jumps to UTILITY > SYSTEM > GAIN.", + 160 + ], + [ + [ + "shift" + ], + "p13", + "S", + "UTILITY", + "Utility menu", + "System settings, rename, factory reset.", + 111 + ], + [ + [ + "shift" + ], + "p14", + "S", + "IMP/EXP", + "Import / export", + "SD import and export, backup and restore, format SD card.", + 123 + ], + [ + [ + "shift" + ], + "p15", + "S", + "PAD SET", + "Pad settings", + "Pad LED mode, bus colors and other pad behaviour.", + 112 + ], + [ + [ + "shift" + ], + "p16", + "S", + "EFX SET", + "Effect settings", + "BUS 3/4 effects, side chain, DIRECT FX button assignment, input FX, routing.", + 113 + ], + [ + [ + "shift" + ], + "busfx", + "S P", + "MUTE BUS", + "Mute bus", + "Cuts the feed into BUS 1/2 so only reverb or delay tails ring out. Turning it off also turns the bus effect off.", + 34 + ], + [ + [ + "shift" + ], + "hold", + "S", + "PAUSE", + "Pause all", + "Pauses every playing sample; again to resume.", + 28 + ], + [ + [ + "shift" + ], + "ext", + "S", + "IN SET", + "Input settings", + "Opens the input setting screen.", + 160 + ], + [ + [ + "shift" + ], + "subpad", + "S", + "PROJECT", + "Project select", + "Pads load projects 1–16. Inside this screen, SUB PAD loads projects straight from SD (/EXPORT/PROJECT/PROJECT_01…16).", + 104 + ], + [ + [ + "shift" + ], + "mark", + "S", + "SAVE FX", + "Save BUS 1/2 effects", + "Hold MARK for 3 seconds. Saves the main parameters of the BUS 1/2 effects so they survive power-off.", + 33, + "Hold SHIFT, hold MARK 3 s" + ], + [ + [ + "shift" + ], + "pitch", + "S E", + "ENVELOPE", + "Envelope", + "Attack, hold, release per sample.", + 53 + ], + [ + [ + "shift" + ], + "start_end", + "S E", + "MARKERS", + "Marker / chop screen", + "Place markers with CTRL 1 + MARK, or tap pads 2–16 in time while pad 1 plays. VALUE opens AUTO MARK, ASSIGN TO PAD (chop), DELETE ALL MARKERS.", + 50 + ], + [ + [ + "shift" + ], + "pat_sel", + "P L", + "UNDO", + "Undo last take", + "While recording a pattern: undo what you just recorded. In the looper: undo, and again to redo.", + 66 + ], + [ + [ + "shift" + ], + "roll", + "S", + "ROLL RATE", + "Roll interval", + "Cycles 1/4 down to 1/64, then the triplet values.", + 23 + ], + [ + [ + "shift" + ], + "loop", + "S", + "PINGPONG", + "Ping-pong loop", + "Loop alternates forward and backward (LOOP blinks).", + 22 + ], + [ + [ + "shift" + ], + "gate", + "S", + "GATE ALL", + "Gate all in bank", + "Toggles GATE for every sample in the bank.", + 21 + ], + [ + [ + "shift" + ], + "bpm_sync", + "S", + "SYNC ALL", + "BPM sync all in bank", + "Toggles BPM SYNC for every sample in the bank.", + 20 + ], + [ + [ + "shift" + ], + "rec", + "P", + "TR-REC", + "Jump into TR-REC", + "While a pattern plays: step-record on top of it.", + 64 + ], + [ + [ + "shift" + ], + "banks", + "S", + "BANK VOL", + "Bank volume", + "Level for a whole bank, to even out banks.", + 19 + ], + [ + [ + "shift" + ], + "remain", + "S", + "LIVE", + "Live mode", + "Hold REMAIN 3 seconds. Disables DEL, REC, RESAMPLE, COPY and MARK so you cannot wreck a set. Same again to exit.", + 29, + "Hold SHIFT, hold REMAIN 3 s" + ], + [ + [ + "shift" + ], + "copy", + "S", + "PROTECT", + "Bank protect", + "Locks samples and patterns of the selected bank. Same again to unlock.", + 62 + ], + [ + [ + "shift" + ], + "value", + "S", + "MARK FN", + "MARK = skip-back or looper", + "Pressing the VALUE knob flips what MARK does.", + 44, + "Hold SHIFT, press VALUE" + ], + [ + [ + "shift" + ], + "resample", + "S", + "MERGE", + "Sample merge", + "Pick up to 4 pads. CTRL 1: SUM (mix down) or MUL (multiply, ring-mod style). CTRL 3 per-pad level. Pick destination, press VALUE.", + 27 + ], + [ + [ + "shift" + ], + "rec_set", + "S", + "SYNTH", + "Sound generator", + "Built-in oscillator (sine to noise) playable on pads or via MIDI notes; sample the result.", + 46 + ], + [ + [ + "shift" + ], + "exit", + "S P D L", + "STOP", + "Stop everything", + "Stops all samples and patterns. Same as tapping EXIT four times fast.", + 28 + ], + [ + [ + "shift" + ], + "c1", + "S E", + "FINE", + "Fine CTRL 1", + "Finer steps for REC BPM, SPEED and similar values.", + 56, + "Hold SHIFT, turn CTRL 1" + ], + [ + [ + "shift" + ], + "value", + "E", + "NUM/VINYL", + "Numeric entry / vinyl mode", + "START/END screen: press VALUE to type point positions on pads 1–10. PITCH/SPEED screen: turn VALUE to toggle VINYL mode (speed and pitch linked like a turntable).", + 48, + "Hold SHIFT, press or turn VALUE" + ], + [ + [ + "shift" + ], + "c2", + "E", + "FINE PIT", + "Fine pitch", + "PITCH/SPEED screen: cents. START/END and marker screens: SHIFT + any CTRL zooms.", + 57, + "Hold SHIFT, turn CTRL 2" + ], + [ + [ + "shift" + ], + "c3", + "E", + "PAN", + "Sample pan", + "PITCH/SPEED screen.", + 57, + "Hold SHIFT, turn CTRL 3" + ], + [ + [ + "shift" + ], + "del", + "P", + "REVERT", + "Revert chain edits", + "Pattern chain editor: discard edits (the * mark).", + 75 + ], + [ + [ + "shift" + ], + "mark", + "P", + "ERASE FX", + "FX motion erase on/off", + "During real-time pattern recording: press once where erasing should start, again where it should stop.", + 78 + ], + [ + [ + "shift", + "reverse" + ], + "pads", + "S P", + "MUTE", + "Pad mute", + "Mute or unmute pads while they play (muted pads light red). Not on pads holding patterns.", + 26 + ], + [ + [ + "shift", + "reverse" + ], + "remain", + "S", + "LATCH", + "Pad mute latch", + "Keeps SHIFT and REVERSE held so you can tap mutes freely. EXIT cancels.", + 26 + ], + [ + [ + "shift", + "reverse" + ], + "remain", + "P", + "MUTE REC", + "Pad mute motion rec", + "During real-time pattern recording: records your pad mutes into the pattern. DEL flips to erase mode.", + 79 + ], + [ + [], + "exit", + "S P D L", + "STOP ×4", + "Panic stop", + "Four quick taps stop everything and also clear pad mutes.", + 28, + "Tap EXIT four times fast" + ], + [ + [], + "remain", + "S", + "INFO", + "Pad info", + "Hold to see the selected pad's parameters; while it plays, the remaining time.", + 59 + ], + [ + [], + "mark", + "S", + "SKIPBACK", + "Skip-back sampling", + "When MARK blinks, the unit has been capturing the last 25 s (40 s with SBS Long). MARK, then REC, then pick a pad. Lost on EXIT or power-off.", + 41 + ], + [ + [ + "bank_d" + ], + "bank_e", + "S", + "DJ MODE", + "Enter DJ mode", + "Press D/I and E/J together.", + 93, + "Press D/I and E/J together" + ], + [ + [], + "value", + "E", + "MENU", + "Hidden edit menu", + "START/END screen: TRUNCATE, NORMALIZE, EMPHASIS. Marker screen: AUTO MARK, ASSIGN TO PAD, DELETE ALL MARKERS.", + 49, + "Press VALUE on an edit screen" + ], + [ + [], + "resample", + "E", + "0-CROSS", + "Snap to zero-cross", + "On START/END and marker screens a lit RESAMPLE snaps points to zero crossings.", + 49 + ], + [ + [], + "mark", + "E", + "SET S/E", + "Set start/end by ear", + "START/END screen, while playing: MARK sets start, then end. Marker screen: MARK drops a marker at the cursor.", + 48 + ], + [ + [], + "roll", + "E", + "PREV/GROOVE", + "Preview end / groove", + "START/END screen: hold to hear the seconds before the end point. PITCH/SPEED screen: ROLL opens the Groove template screen (humanize, rate, swing).", + 48 + ], + [ + [], + "remain", + "E", + "LOCK PTS", + "Point lock", + "START/END screen: stops the start point from pushing the loop and end points.", + 48 + ], + [ + [], + "del", + "E", + "RESET", + "Reset points", + "START/END screen: resets start/end. Marker screen: deletes the selected marker.", + 48 + ], + [ + [ + "remain" + ], + "ctrls", + "E", + "WHOLE BANK", + "Edit the whole bank", + "PITCH/SPEED or ENVELOPE screen: knob moves while holding REMAIN change every sample in the bank.", + 58, + "Hold REMAIN, turn CTRL" + ], + [ + [ + "copy" + ], + "ctrls", + "E", + "MUTE GRP", + "Edit the mute group", + "Same as above, but only samples in the same mute group.", + 58, + "Hold COPY, turn CTRL" + ], + [ + [ + "mark" + ], + "pads", + "E", + "SILENT", + "Select silently", + "ENVELOPE screen: pick a sample without playing it.", + 53 + ], + [ + [ + "remain" + ], + "pads", + "S", + "BUS", + "Pad → bus", + "Holding REMAIN shows bus colors (orange BUS 1, green BUS 2, white DRY). Tap a pad to toggle it between the bus picked by BUS FX and DRY.", + 34 + ], + [ + [ + "remain" + ], + "banks", + "S", + "BANK BUS", + "Bank → bus", + "Route an entire bank at once.", + 34 + ], + [ + [ + "remain" + ], + "ext", + "S", + "IN BUS", + "Input → bus", + "Route INPUT / LINE IN: lit BUS 1, blinking BUS 2, dark DRY.", + 34 + ], + [ + [ + "remain" + ], + "effects", + "S", + "LOCK", + "Lock FX screen", + "Effect edit screen stays up. VALUE flips main/sub parameters. EXIT or REMAIN to leave.", + 33 + ], + [ + [ + "remain" + ], + "busfx", + "S", + "SWAP 1⇄2", + "Swap BUS 1 and 2", + "Swaps the effects and keeps their values.", + 162 + ], + [ + [ + "remain" + ], + "value", + "S", + "COLOR", + "Pad color", + "With Pad LED Mode set to SAMPLE (SHIFT + pad 15): hold REMAIN, turn VALUE to Color, press, pick.", + 59, + "Hold REMAIN, turn VALUE" + ], + [ + [ + "value" + ], + "pads", + "S P", + "SILENT", + "Select without playing", + "Pick a sample, or a pattern in pattern mode, with no sound.", + 162 + ], + [ + [ + "value" + ], + "busfx", + "S", + "BYP 3/4", + "Bypass BUS 3 and 4", + "Temporarily bypass the master-bus effects.", + 162 + ], + [ + [ + "value" + ], + "subpad", + "S", + "TIGHT", + "Tighten timing", + "Reduces flams when triggering several samples at once.", + 162 + ], + [ + [ + "value" + ], + "effects", + "S", + "GRAB", + "Effect grab", + "Effect buttons become momentary instead of toggles.", + 32 + ], + [ + [ + "value" + ], + "gate", + "S", + "1-SHOT", + "One-shot mode", + "Sample plays once to its end regardless of pad release.", + 162 + ], + [ + [ + "value" + ], + "ctrls", + "S", + "SUB PRM", + "Effect sub-parameters", + "Knobs edit the current effect's hidden second page.", + 32, + "Hold VALUE, turn CTRL" + ], + [ + [ + "mfx" + ], + "pads", + "S", + "FX 1–16", + "Pick MFX by pad", + "Pads select effects 1–16 directly.", + 32 + ], + [ + [ + "mfx" + ], + "fx_djl", + "S", + "#17+", + "Pads = MFX 17–32", + "Shifts the pad map to the second page of effects.", + 32 + ], + [ + [ + "mfx" + ], + "fx_iso", + "S", + "#33+", + "Pads = MFX 33+", + "Third page of effects.", + 32 + ], + [ + [ + "mfx" + ], + "value", + "S", + "BROWSE", + "Browse MFX", + "Turn VALUE or CTRL 3 to scroll the effect list.", + 32, + "Hold MFX, turn VALUE" + ], + [ + [ + "del" + ], + "exit", + "S P", + "DEL BANK", + "Delete bank", + "Choose a bank, confirm with VALUE or DEL.", + 62 + ], + [ + [ + "copy" + ], + "exit", + "S P", + "COPY BANK", + "Copy bank", + "Source bank, turn VALUE, destination bank, press VALUE. CTRL 3 targets another project.", + 61 + ], + [ + [ + "del" + ], + "mark", + "P", + "CLR FX", + "Clear FX motion", + "Pattern edit screen: wipes all recorded effect moves.", + 78 + ], + [ + [ + "del" + ], + "reverse", + "P", + "CLR MUTE", + "Clear mute motion", + "Pattern edit screen: wipes all recorded pad mutes.", + 80 + ], + [ + [ + "del" + ], + "value", + "P", + "DEL PTNS", + "Delete patterns in loop", + "Pattern edit: hold DEL (loop range lights blue), tap pads to pick, press VALUE.", + 81, + "Hold DEL, tap pads, press VALUE" + ], + [ + [ + "del" + ], + "bank_a", + "T", + "CLR BAR", + "Clear bar, this pad", + "TR-REC: erase one measure of the selected pad.", + 69 + ], + [ + [ + "del" + ], + "bank_b", + "T", + "CLR BAR*", + "Clear bar, all pads", + "TR-REC: erase one measure for every pad.", + 69 + ], + [ + [ + "roll" + ], + "pads", + "S", + "ROLL", + "Roll a pad", + "Up to 4 at once. Also: hold a pad, then press ROLL.", + 23 + ], + [ + [ + "roll" + ], + "value", + "S", + "FASTER", + "Speed up a roll live", + "Turn right to shorten the interval while rolling; turning left returns to the set value.", + 24, + "Hold ROLL, turn VALUE" + ], + [ + [ + "roll" + ], + "pads", + "P", + "ROLL", + "Record a roll", + "During real-time recording the roll is written into the pattern.", + 80 + ], + [ + [ + "roll" + ], + "ctrls", + "T", + "KNOB REC", + "Step-record knobs", + "TR-REC in TRIG mode: records CTRL moves per step.", + 70, + "Hold ROLL, turn CTRL" + ], + [ + [ + "hold" + ], + "pads", + "S", + "SUSTAIN", + "Sustain gated pads", + "Hold pads, then press HOLD: gated samples keep playing. HOLD with a looping pad retriggers it from the start.", + 21, + "Hold pad(s), press HOLD" + ], + [ + [ + "hold" + ], + "pads", + "P", + "CHAIN", + "Pattern chain", + "Pick chain 1–16. Then pads add patterns, VALUE moves the cursor, DEL removes, REMAIN sets repeat, SUB PAD plays it.", + 74 + ], + [ + [ + "subpad" + ], + "pads", + "P", + "NOW", + "Switch pattern instantly", + "No waiting for the current pattern to finish.", + 64 + ], + [ + [], + "subpad", + "P", + "CHAIN ▶", + "Play pattern chain", + "Inside the pattern chain screen: start and stop the chain.", + 75 + ], + [ + [], + "mark", + "P", + "FX REC", + "FX motion record", + "During real-time pattern recording: MARK starts recording effect moves and knob turns.", + 77 + ], + [ + [], + "start_end", + "P", + "FX PLAY", + "FX motion playback", + "Blinking: recorded effect moves play back. Unlit: ignored.", + 78 + ], + [ + [], + "resample", + "P", + "RESAMPLE", + "Resample a pattern", + "In pattern mode: RECORD SETTING, ROUTING Mix, pick an empty pad, pick the pattern. Keeps effects, unlike bounce.", + 72 + ], + [ + [], + "rec", + "P", + "REHEARSE", + "Rehearsal toggle", + "While recording a pattern, REC flips between writing and rehearsal (keeps looping, writes nothing).", + 66 + ], + [ + [], + "del", + "P", + "ERASE", + "Erase while recording", + "During pattern recording: DEL enters erase mode; hold pads to wipe their notes as the loop passes.", + 66 + ], + [ + [], + "remain", + "P", + "MODE", + "REMAIN in pattern screens", + "RECORD SETTING: Real-Time (lit) or TR-REC (blinking). Chain screen: repeat on/off. Copy screen: pick only some pads to extract.", + 65 + ], + [ + [], + "pat_edit", + "P", + "EDIT", + "Pattern edit", + "BPM, loop start, length. VALUE: DUPLICATE, CROP. RECORD SETTING: quantize.", + 80 + ], + [ + [], + "rec_set", + "P", + "QUANTIZE", + "Quantize", + "On the pattern edit screen: quantize recorded notes. Cannot be undone.", + 83 + ], + [ + [], + "copy", + "P", + "COPY/BOUNCE", + "Copy, extract, bounce", + "COPY, source, destination. REMAIN in between extracts only chosen pads. To bounce to audio: pick with COPY, press PATTERN SELECT, choose a pad. COPY then HOLD + pad bounces a chain.", + 85 + ], + [ + [ + "subpad" + ], + "pads", + "T", + "PICK", + "Choose pad to program", + "Selects which sample's steps you're editing. Press VALUE too to choose silently.", + 68 + ], + [ + [ + "pat_edit" + ], + "pads", + "T", + "MICRO", + "Microscope", + "Edit a recorded note: CTRL 1 note, CTRL 2 pitch, CTRL 3 velocity, VALUE nudges timing, DEL deletes.", + 71 + ], + [ + [], + "remain", + "T", + "TRIG/TIE", + "Step mode", + "TRIG: each step hits. HOLD STEP: steps tie together (forces GATE on).", + 69 + ], + [ + [], + "hold", + "T", + "SOLO", + "Solo selected pad", + "Hear only the pad you are programming.", + 69 + ], + [ + [], + "pitch", + "T", + "PITCH MD", + "Step pitch mode", + "CHROMATIC: pitch per step (VALUE picks scale). PAD: one fixed pitch.", + 68 + ], + [ + [], + "ctrls", + "T", + "SUB/PIT/VEL", + "Step knobs", + "CTRL 1 substep (or tie length), CTRL 2 pitch, CTRL 3 velocity. With SHIFT: CTRL 2 shuffle, CTRL 3 start offset.", + 68 + ], + [ + [], + "value", + "T", + "BAR", + "Select bar", + "Turn to page through measures.", + 69 + ], + [ + [], + "rec", + "T", + "TAP", + "Tap record", + "Press REC in time to drop the selected pad in. RECORD SETTING enables quantize and shuffle for it.", + 71 + ], + [ + [], + "mark", + "L", + "EXIT", + "Leave looper", + "MARK toggles looper mode (when MARK function is set to Looper).", + 45 + ], + [ + [], + "rec", + "L", + "REC", + "Loop rec / stop", + "Starts and stops recording.", + 45 + ], + [ + [], + "resample", + "L", + "OVERDUB", + "Overdub", + "Light it before recording to switch to overdub after the first pass.", + 44 + ], + [ + [], + "del", + "L", + "CLEAR", + "Clear loop", + "Only when stopped.", + 45 + ], + [ + [], + "copy", + "L", + "TO PAD", + "Save loop to pad", + "Pick a pad, press VALUE.", + 45 + ], + [ + [], + "ctrls", + "L", + "LEN/TRIG/BPM", + "Looper knobs", + "CTRL 1 length (free, 1, 2, 4 bars), CTRL 2 auto trigger, CTRL 3 BPM or play rate.", + 44 + ], + [ + [ + "shift" + ], + "c1", + "L", + "BUS", + "Looper bus", + "DRY, BUS 1, BUS 2 or LOOPER. Use LOOPER when overdubbing.", + 44, + "Hold SHIFT, turn CTRL 1" + ], + [ + [ + "shift" + ], + "c2", + "L", + "ROUTING", + "Looper source", + "Mix (everything) or ExtIn (input only).", + 44, + "Hold SHIFT, turn CTRL 2" + ], + [ + [ + "shift" + ], + "value", + "L", + "LVL/FB", + "Rec level / feedback", + "Press VALUE to choose which one the knob edits.", + 44, + "Hold SHIFT, turn VALUE" + ], + [ + [ + "pitch" + ], + "pads", + "L", + "TEMPO←", + "Take a pad's tempo", + "Looper tempo becomes that pad's BPM.", + 45 + ], + [ + [], + "remain", + "X", + "PLAY MD", + "Legato / mono / poly", + "Chromatic mode: cycles legato (slow blink), mono (lit), poly (fast blink).", + 25 + ], + [ + [], + "value", + "X", + "SCALE", + "Scale and range", + "Chromatic mode: press to cycle major, minor, dorian, phrygian, major and minor pentatonic. Turn to move the key range.", + 26 + ], + [ + [ + "roll" + ], + "value", + "X", + "ROOT", + "Scale root", + "Chromatic mode: hold ROLL and turn VALUE.", + 26, + "Hold ROLL, turn VALUE" + ], + [ + [], + "ctrls", + "X", + "OSC", + "Synth knobs", + "Sound generator: CTRL 1 waveform, CTRL 2 frequency, CTRL 3 level. With SHIFT: pad length, duty cycle, balance.", + 46 + ], + [ + [], + "p13", + "D", + "CH1 ▶‖", + "CH1 play / pause", + "", + 94 + ], + [ + [], + "p15", + "D", + "CH2 ▶‖", + "CH2 play / pause", + "", + 94 + ], + [ + [], + "p9", + "D", + "CH1 ⏮", + "CH1 back to start", + "", + 94 + ], + [ + [], + "p11", + "D", + "CH2 ⏮", + "CH2 back to start", + "", + 94 + ], + [ + [], + "p14", + "D", + "CH1 CUE", + "CH1 to headphones", + "", + 94 + ], + [ + [], + "p16", + "D", + "CH2 CUE", + "CH2 to headphones", + "", + 94 + ], + [ + [], + "p10", + "D", + "SYNC 1→2", + "CH1 follows CH2 tempo", + "", + 94 + ], + [ + [], + "p12", + "D", + "SYNC 2→1", + "CH2 follows CH1 tempo", + "", + 94 + ], + [ + [], + "p2", + "D", + "CH1 BPM+", + "CH1 tempo up", + "", + 94 + ], + [ + [], + "p6", + "D", + "CH1 BPM−", + "CH1 tempo down", + "", + 94 + ], + [ + [], + "p4", + "D", + "CH2 BPM+", + "CH2 tempo up", + "", + 94 + ], + [ + [], + "p8", + "D", + "CH2 BPM−", + "CH2 tempo down", + "", + 94 + ], + [ + [], + "p1", + "D", + "CH1 BEND+", + "CH1 nudge faster", + "Pitch and speed rise while held, like pushing a platter.", + 94 + ], + [ + [], + "p5", + "D", + "CH1 BEND−", + "CH1 nudge slower", + "", + 94 + ], + [ + [], + "p3", + "D", + "CH2 BEND+", + "CH2 nudge faster", + "", + 94 + ], + [ + [], + "p7", + "D", + "CH2 BEND−", + "CH2 nudge slower", + "", + 94 + ], + [ + [ + "del" + ], + [ + "p2", + "p6" + ], + "D", + "CH1 RST", + "CH1 tempo reset", + "", + 94 + ], + [ + [ + "del" + ], + [ + "p4", + "p8" + ], + "D", + "CH2 RST", + "CH2 tempo reset", + "", + 94 + ], + [ + [ + "remain" + ], + "p14", + "D", + "CH1 BUS", + "CH1 bus cycle", + "BUS 1, BUS 2, DRY.", + 94 + ], + [ + [ + "remain" + ], + "p16", + "D", + "CH2 BUS", + "CH2 bus cycle", + "BUS 1, BUS 2, DRY.", + 94 + ], + [ + [ + "roll" + ], + "p13", + "D", + "CH1 ROLL", + "CH1 roll", + "Fails if the roll size is longer than the sample.", + 164 + ], + [ + [ + "roll" + ], + "p15", + "D", + "CH2 ROLL", + "CH2 roll", + "", + 164 + ], + [ + [ + "roll" + ], + "p1", + "D", + "¼ BAR", + "Roll: quarter bar", + "Changes the interval while rolling.", + 164 + ], + [ + [ + "roll" + ], + "p2", + "D", + "½ BAR", + "Roll: half bar", + "", + 164 + ], + [ + [ + "roll" + ], + "p3", + "D", + "1 BAR", + "Roll: 1 bar", + "", + 164 + ], + [ + [ + "roll" + ], + "p4", + "D", + "2 BARS", + "Roll: 2 bars", + "", + 164 + ], + [ + [ + "shift" + ], + "roll", + "D", + "ROLL SZ", + "Roll size", + "1/4, 1/2, 1 or 2 bars. Set before rolling.", + 164 + ], + [ + [ + "resample" + ], + "value", + "D", + "CURVE", + "Fader curve", + "FAST CUT, LINEAR, SQUARE, CUBIC.", + 95, + "Hold RESAMPLE, press VALUE" + ], + [ + [], + "hold", + "D", + "PAD MODE", + "DJ / pad mode", + "Switch between DJ controls and normal pad playback.", + 96 + ], + [ + [], + "start_end", + "D", + "X-FADE", + "CTRL 3: cue mix / crossfade", + "Swaps what CTRL 3 does.", + 94 + ], + [ + [], + "pitch", + "D", + "BPM .00", + "BPM decimals", + "Cycles BPM display precision.", + 163 + ], + [ + [], + "mark", + "D", + "VIEW", + "EFX / mixer view", + "Toggles the screen.", + 163 + ], + [ + [], + "bpm_sync", + "D", + "REV CH", + "Reverse channel", + "Chooses whether REVERSE acts on CH1 or CH2.", + 163 + ], + [ + [ + "shift" + ], + "pads", + "D", + "CUE PT", + "Play from marker", + "Jump to a marker. Pads 1/2 are CH1/CH2 starts, others map to markers 1–7 (manual p. 96).", + 96 + ], + [ + [ + "shift" + ], + "remain", + "D", + "SHIFT LCK", + "Shift latch", + "Keeps SHIFT held for marker juggling. EXIT cancels.", + 163 + ], + [ + [ + "shift" + ], + "mark", + "D", + "+MARKER", + "Add marker", + "Drop markers while playing.", + 97 + ], + [ + [ + "shift" + ], + "start_end", + "D", + "EDIT MRK", + "Edit markers", + "Move markers while playing.", + 97 + ], + [ + [ + "shift", + "del" + ], + "pads", + "D", + "DEL MRK", + "Delete a marker", + "Pad map as for playing from markers. Later markers shift down.", + 98 + ], + [ + [ + "shift", + "reverse" + ], + [ + "p13", + "p15" + ], + "D", + "MUTE", + "Channel mute", + "Pad 13 mutes CH1, pad 15 mutes CH2.", + 163 + ] +]; diff --git a/src/domain/types.ts b/src/domain/types.ts new file mode 100644 index 0000000..0dd5cab --- /dev/null +++ b/src/domain/types.ts @@ -0,0 +1,67 @@ +/** Domain types, mirroring `reference/index.html` — still the data spec. */ + +/** A control on the panel: `"shift"`, `"p1"`…`"p16"`, `"bank_a"`, and so on. */ +export type ControlId = string; + +/** A named set of controls an entry can target instead of a single control. */ +export type GroupId = "pads" | "ctrls" | "effects" | "banks"; + +/** + * Where you are on the device. One entry can apply in several at once. + * + * `S` sample mode · `E` edit screens · `P` pattern · `T` TR-REC · + * `L` looper · `D` DJ mode · `X` chromatic / synth + */ +export type ContextCode = "S" | "E" | "P" | "T" | "L" | "D" | "X"; + +/** + * One row of the reference's `RAW` table, positional exactly as authored: + * `[mods, target, ctx, short, name, desc, page, how?]`. + * + * `target` is one control, a group name, or an explicit list of controls. + * `ctx` is a space-separated string of context codes. + * `how` overrides the generated "hold X, press Y" line when the gesture is + * unusual — a three-second hold, a knob turn, four quick taps. + */ +export type RawEntry = [ + mods: ControlId[], + target: ControlId | GroupId | ControlId[], + ctx: string, + short: string, + name: string, + desc: string, + page: number, + how?: string, +]; + +/** + * A `RawEntry` with everything the UI needs precomputed. + * + * Derived once at module load, like the reference's `ENTRIES`. `key` identifies + * the layer an entry lives on; `tset` is the target expanded to concrete + * controls; `id` is stable across runs so drill progress survives a reload. + */ +export interface Entry { + /** Index in `RAW`, kept so an entry can be addressed compactly. */ + readonly index: number; + /** Modifiers held, sorted into `MODORDER` so the key is canonical. */ + readonly mods: ControlId[]; + /** `mods` joined with `+`. The empty string is the no-modifier layer. */ + readonly key: string; + /** The target as authored, before expansion. */ + readonly target: ControlId | GroupId | ControlId[]; + /** The target expanded to the concrete controls it lights up. */ + readonly tset: ControlId[]; + /** Context codes this entry applies in. */ + readonly ctx: ContextCode[]; + /** Panel-sized label, e.g. `"FIX VEL"`. */ + readonly short: string; + /** Human name, e.g. `"Fixed velocity"`. */ + readonly name: string; + readonly desc: string; + /** Page in the v5.50 reference manual. */ + readonly page: number; + readonly how?: string; + /** Stable identity used as the drill progress key. */ + readonly id: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..acdff55 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..46bf8d0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +/** + * Test-only config for now. + * + * The UI phase adds `vite.config.ts` for the build; until then nothing runs Vite + * itself, and `www/` is still produced by `scripts/sync-www.sh` for the Phase 1 + * APK. Keeping the two configs separate means starting the React build cannot + * quietly break the wrapper that currently works. + */ +export default defineConfig({ + test: { + // Domain code is plain TypeScript with no DOM, so the default node + // environment is enough. The UI phase adds jsdom if we test components. + environment: "node", + include: ["src/**/*.test.ts"], + }, +});