From 8d501cde8c8494d28230a897de4e6637bf6ab009 Mon Sep 17 00:00:00 2001 From: Vladyslav Vladinov Date: Sun, 4 Oct 2026 20:26:03 +0200 Subject: [PATCH 1/8] feat: the port to React, TypeScript and Vite ADR-0002 item 1. `site/` becomes the Vite project of D7: React 19 with the Rust React Compiler, `sqzer` 0.3.0 from npm served from the page's own origin, the Content-Security-Policy written once in `vite.config.ts` with jsDelivr gone. The worker, its client and the message types are in `src/shared/api/sqzer/`, and the search is an XState machine with the worker as a callback actor. The page behaves as it did. ADR-0004, proposed: the worker outlives a search, as on `main`, and slices are merged into `src/pages/compress/` until something is reused. A worker per search, as ADR-0002 D10 had it, made a change of quality on a held image take 0.95 s where `main` takes 0.37 s. `check.yml`, `pages.yml` and `dependabot.yml` still name `site/`: items 2 to 4 of ADR-0002. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 1 + .greptile/config.json | 6 +- .greptile/files.json | 21 +- docs/adr/0002-stack.md | 3 +- docs/adr/0004-worker-lifetime.md | 134 ++ docs/adr/README.md | 1 + index.html | 14 + package.json | 29 + pnpm-lock.yaml | 1160 ++++++++++++++++ site/index.html | 61 - site/main.js | 372 ----- site/package.json | 9 - site/selftest/checks.js | 151 -- site/selftest/fixtures/pattern-rgb.heic | Bin 769 -> 0 bytes site/selftest/fixtures/pattern-rgb.jpg | Bin 673 -> 0 bytes site/selftest/fixtures/pattern-rgb.svg | 1540 --------------------- site/selftest/index.html | 33 - site/selftest/worker.js | 6 - site/worker.js | 77 -- src/app/main.tsx | 15 + {site => src/app}/style.css | 9 +- src/pages/compress/index.ts | 2 + src/pages/compress/lib/codec.ts | 16 + src/pages/compress/lib/draw.ts | 41 + src/pages/compress/lib/options-of.ts | 23 + src/pages/compress/lib/summary.ts | 33 + src/pages/compress/model/context.ts | 11 + src/pages/compress/model/encode-result.ts | 15 + src/pages/compress/model/encoder.ts | 116 ++ src/pages/compress/model/machine.ts | 226 +++ src/pages/compress/model/picked.ts | 22 + src/pages/compress/ui/comparison.tsx | 48 + src/pages/compress/ui/compress-page.tsx | 53 + src/pages/compress/ui/control-panel.tsx | 74 + src/pages/compress/ui/drop-zone.tsx | 63 + src/pages/compress/ui/result-panel.tsx | 22 + src/pages/compress/ui/search-status.tsx | 22 + src/shared/api/index.ts | 2 + src/shared/api/sqzer/client.ts | 30 + src/shared/api/sqzer/protocol.ts | 52 + src/shared/api/sqzer/worker.ts | 76 + src/shared/lib/object-url.ts | 21 + tsconfig.json | 19 + vite.config.ts | 32 + 44 files changed, 2399 insertions(+), 2262 deletions(-) create mode 100644 docs/adr/0004-worker-lifetime.md create mode 100644 index.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml delete mode 100644 site/index.html delete mode 100644 site/main.js delete mode 100644 site/package.json delete mode 100644 site/selftest/checks.js delete mode 100644 site/selftest/fixtures/pattern-rgb.heic delete mode 100644 site/selftest/fixtures/pattern-rgb.jpg delete mode 100644 site/selftest/fixtures/pattern-rgb.svg delete mode 100644 site/selftest/index.html delete mode 100644 site/selftest/worker.js delete mode 100644 site/worker.js create mode 100644 src/app/main.tsx rename {site => src/app}/style.css (81%) create mode 100644 src/pages/compress/index.ts create mode 100644 src/pages/compress/lib/codec.ts create mode 100644 src/pages/compress/lib/draw.ts create mode 100644 src/pages/compress/lib/options-of.ts create mode 100644 src/pages/compress/lib/summary.ts create mode 100644 src/pages/compress/model/context.ts create mode 100644 src/pages/compress/model/encode-result.ts create mode 100644 src/pages/compress/model/encoder.ts create mode 100644 src/pages/compress/model/machine.ts create mode 100644 src/pages/compress/model/picked.ts create mode 100644 src/pages/compress/ui/comparison.tsx create mode 100644 src/pages/compress/ui/compress-page.tsx create mode 100644 src/pages/compress/ui/control-panel.tsx create mode 100644 src/pages/compress/ui/drop-zone.tsx create mode 100644 src/pages/compress/ui/result-panel.tsx create mode 100644 src/pages/compress/ui/search-status.tsx create mode 100644 src/shared/api/index.ts create mode 100644 src/shared/api/sqzer/client.ts create mode 100644 src/shared/api/sqzer/protocol.ts create mode 100644 src/shared/api/sqzer/worker.ts create mode 100644 src/shared/lib/object-url.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index 646ac51..bf56190 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store node_modules/ +dist/ diff --git a/.greptile/config.json b/.greptile/config.json index 33b2ab3..b5edace 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -40,7 +40,7 @@ }, { "id": "work-in-the-worker", - "rule": "Every call into the package happens in the worker in `src/shared/api/sqzer/`. Only that slice's client starts or ends the worker, and only the search machine in `src/features/encode-image/model/` uses the client, as an invoked callback actor whose cleanup terminates the worker. A search cannot be interrupted, so cancelling means stopping the actor, which ends the worker; that stays the only way. Flag an import of `sqzer` outside the worker, a `new Worker` outside the client, the client used outside the machine, and any attempt to interrupt an encode otherwise.", + "rule": "Every call into the package happens in the worker in `src/shared/api/sqzer/`. Only that slice's client starts or ends the worker, and only the search machine in `src/pages/compress/model/` uses the client, as a callback actor invoked by the machine's `open` state, whose cleanup terminates the worker (ADR-0004). The worker outlives a search and keeps the decoded image. A search cannot be interrupted, so cancelling means re-entering `open`, which stops the actor and ends the worker; that stays the only way. Flag an import of `sqzer` outside the worker, a `new Worker` outside the client, the client used outside the machine, a worker ended while it is idle, and any attempt to interrupt an encode otherwise.", "scope": [ "src/**" ], @@ -105,8 +105,8 @@ "id": "user-facing-prose", "rule": "User-facing text (the page's copy, README, contributor docs, issue forms, PR template, docs): sentence-case headings, backticks on every identifier, flag and path, no em dashes, no emoji, no tables, explanation in `#` comments above a command rather than prose around it, uncertainty stated flatly with the next step named.", "scope": [ - "site/index.html", - "site/main.js", + "index.html", + "src/**", "README.md", "CONTRIBUTING.md", "SECURITY.md", diff --git a/.greptile/files.json b/.greptile/files.json index 1ef894d..e9cf7ad 100644 --- a/.greptile/files.json +++ b/.greptile/files.json @@ -13,14 +13,16 @@ "description": "The index of design and behaviour decisions, and which decisions taken in sqzer-dev/sqzer govern this page", "scope": [ "docs/adr/**", - "site/**" + "src/**", + "index.html" ] }, { "path": "docs/adr/0001-page-design.md", "description": "The design of the page: the drop zone as the empty state, the image on the whole screen under glass panels, the target first and every package option behind Advanced, untouched controls sending nothing, the search shown as it runs, the result as size, difference and chips, errors as alerts, the privacy claim, the wordmark, the accessibility floor", "scope": [ - "site/**" + "src/**", + "index.html" ] }, { @@ -49,11 +51,18 @@ ] }, { - "path": "site/worker.js", - "description": "The message protocol between the page and the worker, at the top of the file, and how the package version is read", + "path": "docs/adr/0004-worker-lifetime.md", + "description": "The worker's lifetime and the layout: the worker actor is invoked by the machine's `open` state and keeps the decoded image between searches, cancelling is re-entering `open`, every message names its image, and slices are merged into `src/pages/compress/` until something is reused", + "scope": [ + "src/**" + ] + }, + { + "path": "src/shared/api/sqzer/protocol.ts", + "description": "The messages between the page and the worker: what the client sends, what the worker answers, and the stages a failure names", "scope": [ - "site/main.js", - "site/worker.js" + "src/shared/api/**", + "src/pages/compress/model/**" ] } ] diff --git a/docs/adr/0002-stack.md b/docs/adr/0002-stack.md index adbeddd..6ba55fc 100644 --- a/docs/adr/0002-stack.md +++ b/docs/adr/0002-stack.md @@ -1,6 +1,7 @@ # ADR-0002: The stack **Status:** Accepted **Date:** 2026-10-03 **Deciders:** Vlad (sole maintainer) +**Superseded by:** ADR-0004 for the worker's lifetime in D10 and the slice list of D5: the worker lives as long as the machine's `open` state and keeps the decoded image between searches, and slices are merged into `src/pages/compress/` until something is reused **Scope:** What the page is built with: the framework, the language, the bundler, the React APIs the page leans on, how the search's state is held, how `sqzer` reaches the page, the Content-Security-Policy under a build, the source layout, the checks, the deploy and the dependency updates. The design system, the component library and the styling system are ADR-0003 (ADR-0001 D10). What the page looks like and does is ADR-0001, and nothing here changes it. --- @@ -307,7 +308,7 @@ What changes elsewhere: ## 6. Action items -1. [ ] The port: `site/` becomes the Vite project of D7, `main.js` and `worker.js` become TypeScript under D4 and D5 with the search as the machine of D10, and the page behaves exactly as it does today. The redesign of ADR-0001, with D9, starts after it. +1. [x] The port: `site/` becomes the Vite project of D7, `main.js` and `worker.js` become TypeScript under D4 and D5 with the search as the machine of D10, and the page behaves exactly as it does today. The redesign of ADR-0001, with D9, starts after it. 2. [ ] The checks of D6 in `check.yml`, in the `browser` job, with `/selftest/` moved into Vitest browser mode and `.github/check.mjs` into the Playwright suite. Their first green run in CI is the browser check this record could not make. 3. [ ] `pages.yml` builds and uploads `dist/`. 4. [ ] `dependabot.yml`: the `npm` ecosystem at the root, `sqzer` alone, the tooling grouped. diff --git a/docs/adr/0004-worker-lifetime.md b/docs/adr/0004-worker-lifetime.md new file mode 100644 index 0000000..e8e0922 --- /dev/null +++ b/docs/adr/0004-worker-lifetime.md @@ -0,0 +1,134 @@ +# ADR-0004: The worker outlives a search + +**Status:** Proposed **Date:** 2026-10-04 **Deciders:** Vlad (sole maintainer) +**Scope:** How long the worker lives and how a search is cancelled, which ADR-0002 D10 decided, and where the page's code sits, which ADR-0002 D5 sketched. Nothing else in ADR-0002 changes, and nothing in ADR-0001 or ADR-0003. + +--- + +## 1. Context + +ADR-0002 D10 made the worker a callback actor invoked by `searching`. Leaving that state for any reason ends the worker, a finished search included. The page on `main` does it differently: one worker lives as long as the tab, and it is ended only to cancel. + +The port was built the way D10 says first, and both pages were measured in headless Chromium and Firefox: + +```text +# a 4000x2250 JPEG, format `jpeg`, fixed quality, width 1600. the time from a change of +# quality to the result on the page, the 250 ms the controls rest included + Chromium Firefox +the page on `main` 0.37 s 0.57 s +a worker per search, D10 0.95 s 1.15 s +``` + +The package keeps the decoded and the resized image between calls, "so a quality slider only pays for the encode", as its README says. A worker per search throws that away: every change of a control starts a worker, instantiates 8 MB of wasm, decodes and resizes again. ADR-0001 puts a target control and an Advanced panel on the page, and each of them would pay this. + +The port also ran Steiger for the first time. The page has one route, so six of the slices D5 names are used in one place each, and `fsd/insignificant-slice` flags all six. + +--- + +## 2. Decision + +### D1. The worker lives as long as the machine's `open` state + +The machine gets one state above the four of D10. `open` invokes the worker actor, and `empty`, `searching`, `result` and `failed` are its children. The worker starts with the page, so `codecs()` and the version reach the controls before any image, and it keeps the decoded image between searches. A search that ends in `result` or in `failed` leaves the worker running. + +```text +open invokes the worker actor + empty no image yet + searching + reading the worker decodes the image, or the page draws it + encoding the search, or one encode at a fixed quality + result + failed +``` + +With this, a change of quality on the photo above takes 0.37 s in Chromium and 0.55 s in Firefox, the same as on `main`. + +### D2. Cancelling is re-entering `open` + +A `restart` event re-enters `open`. That stops the actor, and the actor's cleanup terminates the worker. It is still the only way an encode is cancelled, and still held by the machine: no code outside it ends a worker. + +```text +a new image during a search at once. the worker is busy with the image that left +the controls move during a search after 300 ms. if the worker finishes what it was doing + first, it stays and encodes again with the newest options +a failure while newer options wait at once, so the newest options get their run +a search asked of a worker that at once +failed to start or to load +``` + +The 300 ms are the `PATIENCE_MS` of `site/main.js`. A worker that is idle is never ended: a new image or new options go to the one that is there. + +### D3. Every message names its image + +A worker that outlives its image can still answer for it. Each request carries a number for the image it is about, the worker repeats it in the reply, and the actor drops a reply about an image that was replaced since. `site/worker.js` did the same with `id`. Its `job` is gone: the machine has one encode running at a time and marks it `outdated` when the controls move. + +### D4. Slices are merged until something is reused + +`fsd/insignificant-slice` stays on. A slice exists once two places use it, and until then its code sits with its one user. For a page with one route that is the page: + +```text +src/app/ the root and the global stylesheet +src/pages/compress/ui/ the page and its parts: drop zone, controls, status, comparison, result +src/pages/compress/model/ the search machine, the worker actor, the picked image, the result +src/pages/compress/lib/ drawing on the page, codec lookups, the summary, reading the controls +src/shared/api/sqzer/ the worker, its client and the message types (ADR-0002 D4), + behind `src/shared/api/index.ts` +src/shared/lib/ small helpers, named by their domain +``` + +The search machine is in `src/pages/compress/model/`, not in `src/features/encode-image/model/`. When a second place needs a part, it moves down to the layer D5 names for it. `src/shared/ui/` arrives with ADR-0003. + +--- + +## 3. Options considered + +**A worker per search, as D10 had it.** The smallest machine: four flat states and an actor that runs one script. Rejected for the numbers in section 1. + +**Cancelling inside the package**, so that a worker never has to be ended. Not possible in a form the page can use today: + +- The search calls `Encoder::encode` once per trial, and that is one blocking call into a third-party encoder with no way to stop it. The earliest a search could stop is between two trials. ADR-0011 measured 18 seconds for a search on a 12 megapixel photo. A search is at most six trials, so that is about three seconds each. +- While a trial runs, the worker's event loop is blocked, so a `cancel` message is not delivered until the whole search is over. Reaching a blocked worker takes a `SharedArrayBuffer`, which needs the isolation headers GitHub Pages cannot send. The other way is a search that yields between trials, which needs JSPI, experimental in `wasm-bindgen`, or the search loop of `sqzer-metrics` rebuilt as steps. ADR-0011 names both limits. +- Ending the worker stops an encode at once. + +It comes back if the hosting can send the headers or an encoder gains a hook. It would be a change to `sqzer` first and a version bump here after. + +**A spawned actor**, stopped and spawned again by actions, with the four states flat. Rejected: the worker's lifetime would be held by two actions that have to stay in step. With `open` it is held by a state. + +**Turning `fsd/insignificant-slice` off** and keeping the slices of D5. Rejected: a slice with one user is a folder to walk through, and merging costs nothing to undo when the second user comes. + +--- + +## 4. Trade-offs + +**A bigger machine.** `open`, the patience, the image numbers, and three flags, `held`, `outdated` and `broken`, where D10 had four states and no flag. All of it is what `main.js` already held in variables. + +**The decoded image stays in memory** for as long as the tab shows it, as on `main`. A worker per search gave it back after every result. + +**One slice holds the page.** Steiger checks the layers, not the folders inside a slice, so `ui`, `model` and `lib` under `pages/compress` are kept apart by review until parts of them move out. + +--- + +## 5. Consequences + +- ADR-0002 gets a `Superseded by` line for the worker's lifetime in D10 and for the slice list of D5. The rest of D10 stands: an XState machine, the worker as a callback actor, the client used by nothing else. +- The Greptile rule `work-in-the-worker` names `src/pages/compress/model/` and the `open` state. This record gets its entry in `.greptile/files.json`. +- The rule in `CLAUDE.md` holds as it is written: a search cannot be interrupted, and ending the worker is the only way an encode is cancelled. + +--- + +## 6. Action items + +1. [x] The machine of D1 to D3 and the layout of D4, in the port of ADR-0002 item 1. +2. [ ] Steiger in `check.yml` with `fsd/insignificant-slice` on, with ADR-0002 item 2. + +--- + +## Sources + +- ADR-0002 D4, D5 and D10, in this directory +- `site/main.js` and `site/worker.js` on `main`, for `PATIENCE_MS`, `id` and `job` +- The package's README, "Decode once, encode many times": https://github.com/sqzer-dev/sqzer/tree/main/crates/sqzer-wasm +- ADR-0011 of `sqzer-dev/sqzer`, for the single thread, the isolation headers and JSPI: https://github.com/sqzer-dev/sqzer/blob/main/docs/adr/0011-browser-build.md +- `Search::encode_with` in `crates/sqzer-metrics/src/search.rs` and `Encoder::encode` in `crates/sqzer-core/src/codec.rs`, `sqzer-dev/sqzer` +- XState, re-entering a state: https://stately.ai/docs/transitions#re-entering +- Steiger, `insignificant-slice`: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice diff --git a/docs/adr/README.md b/docs/adr/README.md index e08aa63..b94860f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -6,6 +6,7 @@ One file per decision, numbered, never edited after acceptance. A superseded rec 0001-page-design.md the design of the page, from references Accepted 0002-stack.md the stack: React 19, Vite, XState, `sqzer` from npm Accepted 0003-design-system.md the components and the look: shadcn on Base UI, Geist, Radix Colors, glass Accepted +0004-worker-lifetime.md the worker outlives a search, slices merge until something is reused Proposed ``` The decisions this page is built on were taken in [`sqzer-dev/sqzer`](https://github.com/sqzer-dev/sqzer) and stay there: [`docs/adr/0011-browser-build.md`](https://github.com/sqzer-dev/sqzer/blob/main/docs/adr/0011-browser-build.md), D3 for the package's API and D6 for this page: its own repository, the published package by version from jsDelivr, a static page without a framework or a build step, nothing sent anywhere, the licence line it sits on. diff --git a/index.html b/index.html new file mode 100644 index 0000000..aa07e42 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + +sqzer + + + + +
+ + diff --git a/package.json b/package.json new file mode 100644 index 0000000..73fd3d3 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "sqzer.dev", + "private": true, + "description": "The page at sqzer.dev.", + "license": "MIT OR Apache-2.0", + "type": "module", + "packageManager": "pnpm@12.8.1", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@xstate/react": "6.1.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "sqzer": "0.3.0", + "xstate": "5.33.2" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "oxc-transform-react": "0.145.0", + "typescript": "7.0.2", + "vite": "8.3.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ef9d2bb --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1160 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.8.1 + version: 12.8.1 + +packages: + + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + cpu: [x64] + os: [win32] + + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.8.1': + optional: true + + '@pnpm/exe.android-x64@12.8.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.8.1': + optional: true + + '@pnpm/exe.darwin-x64@12.8.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64@12.8.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.8.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.8.1': + optional: true + + '@pnpm/exe.linux-s390x@12.8.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-x64@12.8.1': + optional: true + + '@pnpm/exe.win32-arm64@12.8.1': + optional: true + + '@pnpm/exe.win32-x64@12.8.1': + optional: true + + pnpm@12.8.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@xstate/react': + specifier: 6.1.0 + version: 6.1.0(@types/react@19.3.0)(react@19.3.0)(xstate@5.33.2) + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + sqzer: + specifier: 0.3.0 + version: 0.3.0 + xstate: + specifier: 5.33.2 + version: 5.33.2 + devDependencies: + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.1 + version: 6.1.1(oxc-transform-react@0.145.0)(vite@8.3.2) + oxc-transform-react: + specifier: 0.145.0 + version: 0.145.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.3.2 + version: 8.3.2 + +packages: + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@oxc-transform-react/binding-android-arm-eabi@0.145.0': + resolution: {integrity: sha512-KuK3zAp10yFyeAxIXlZn2ycsFpaKLxUmj5BVRS1io4XfwnKTozb/goZakgKD5JEiHOVOtEKt1r7pTrmW/pFxxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-transform-react/binding-android-arm64@0.145.0': + resolution: {integrity: sha512-eDUKx6MAgRAF67JPScjLF6h8lL2qmlvFrXGSG8mYFIVJb++NMyDygiIo8TAKdWQNguoBElUZw/SK2EPRaKsryQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-transform-react/binding-darwin-arm64@0.145.0': + resolution: {integrity: sha512-msCgwbVLswJ08JaUOjfPddieVJwE4IT1Y23A1DDyKVyzZp2ormo9a4ffDEu9nmmovdEj8VqAyFgggsgzIkBVrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-transform-react/binding-darwin-x64@0.145.0': + resolution: {integrity: sha512-R0PtsoOCsARulmWhLG1fFInT+98TRl/H6w6ifi8XOLvpdsksD1rxsw4Ol84lHbdHCcMNjfJTckZzQIZBMiXkLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-transform-react/binding-freebsd-x64@0.145.0': + resolution: {integrity: sha512-/MJ7+IvxfutSGqn6hIXU92wGNk5FlkqbTHM+kEl7ytLLOSAyivYhF2gxdYMmXTwOG56k2XviMcE0D/TBjdyoRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-transform-react/binding-linux-arm-gnueabihf@0.145.0': + resolution: {integrity: sha512-ssHZ0W2wSjQwaBQdkg8u+A7dTdw2vkGSK0hRfCoDCkRkETu+19Q8Tae7NNxlYHcP1iUQYhEj8Qfgpq+S/MdrYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform-react/binding-linux-arm-musleabihf@0.145.0': + resolution: {integrity: sha512-adJy4lQVcoIW6CUzQYbDgbHZMDSLoizwCinG2Dex29jdACKwIgXQQrIAHAgfveaZleKIB/rf3usGoRcOGyfz7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform-react/binding-linux-arm64-gnu@0.145.0': + resolution: {integrity: sha512-7KRa0CC1FaSKx5sy+bDtqGvXF+RiVoMmSe8OmTKm9kRZkNVdmsmpnsQhgravehTohlWDveo5aoGMwcFtffGDmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-arm64-musl@0.145.0': + resolution: {integrity: sha512-fGhZkDnk2RoMp6THas9HFGorF0A1wwPPA/2Lu9IUO1ip5MJqS/JG7/conmiz0DBG0PWBtnKl2Y8sA/VBiD/oQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-linux-ppc64-gnu@0.145.0': + resolution: {integrity: sha512-VBaUTjRZYRypD2tL7cmbM2Fn6fvTnacLQ1GdsU1Y6A3fkqA/z+wc5Vy1QnnuFe9sIwb9xCvSS2tJVlucR39FYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-riscv64-gnu@0.145.0': + resolution: {integrity: sha512-s2cimMVAmWe9dtjyWdGsYtevi9C7gJoOXwrCZD0BX7hgyGmuBO3iWQjx4W48hdXlWS176NoxuFIHAN72N86upA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-riscv64-musl@0.145.0': + resolution: {integrity: sha512-hFPrsL2nKL4aa8vFmheR53PcrxQgym8bnaSD1pMX9IoyQq+/ksAuw1CDJSV9c5xhxxZdw/KTPVer7M/6OQvfoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-linux-s390x-gnu@0.145.0': + resolution: {integrity: sha512-YLKhySqqu0JAtT0z8GuaFF7Hht3IvQLZShTH0DxeV28NwSthva+qJ7GN09ALT1bqRVvMjNGycgPHaGSWBUIt7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-x64-gnu@0.145.0': + resolution: {integrity: sha512-WqgJ+0M9mwQ1cRfSgk4vzYudD2imLSBhUZ2QFes06vUjigts5Dw4NajWKc2JibPHYsoiUA9Zj39zWwC5AxXEGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-x64-musl@0.145.0': + resolution: {integrity: sha512-eIre/TjGt07IVKoWUrUOkPxN7UKeWxbiGvCywoGLDRe1/2K50MrA2vQahZ8AlW5CLmajOr9ePKrkRzLg1fa0cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-openharmony-arm64@0.145.0': + resolution: {integrity: sha512-zhWiyJ+9XD4cuhSZIT6aozuXDHcZHNdoVe88xVmx2XN00anjZ6VAGj31rhD8ndha/F8NllcQEr8Q61Dr2MHv5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-transform-react/binding-win32-arm64-msvc@0.145.0': + resolution: {integrity: sha512-Q0oiZfrfYyhmpqH2K0dylDTooIadecGsoiQks7G0AbuszCu2I7zYyJo4ArFc3d5Hj49Jjc5dDAE5WP/Sg2p8hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-transform-react/binding-win32-ia32-msvc@0.145.0': + resolution: {integrity: sha512-ERpriVJY5S8hXd3BJi4UtzsIVsEQnntmpqTgRqh0XCaflLRLTQnPWpkO8rgtI7xye5MRhfhA+qsxKg2olJs4qA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-transform-react/binding-win32-x64-msvc@0.145.0': + resolution: {integrity: sha512-sa781BGPLDw8+7ATrgUpLr0f4t0Q14O+zk3fOuQbUpjW5jERc9a3k1xIYKX1JkTG/sszoFhi8LzyCoMC49tkWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + 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 + + '@xstate/react@6.1.0': + resolution: {integrity: sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + xstate: ^5.28.0 + peerDependenciesMeta: + xstate: + optional: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oxc-transform-react@0.145.0: + resolution: {integrity: sha512-y8cfjow11WKvDekonySPVvRUvNuIvqNT0LZ0Aey6j5dsNUD6k4hy6Mae/SLmng5egmb1GdJS11GduJ+IoEAXEw==} + engines: {node: ^20.19.0 || >=22.12.0} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + sqzer@0.3.0: + resolution: {integrity: sha512-mffC/kcYk3Ayj5OJhV7Skg8Tnx5Qx1B2akJpVuPuNbedg7UlR65DgWorRwA0TjCR8RrsM01ZBiBgDiG17f/poQ==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + + xstate@5.33.2: + resolution: {integrity: sha512-8tC7yXgeCvpT8gKEeEje6ikJJG1wpnoLiXe+HfECW8m10ubMd3QxKOwWA8KxPJVBcLwfdRoMKYxIBGYKmo37/A==} + +snapshots: + + '@oxc-project/types@0.152.0': {} + + '@oxc-transform-react/binding-android-arm-eabi@0.145.0': + optional: true + + '@oxc-transform-react/binding-android-arm64@0.145.0': + optional: true + + '@oxc-transform-react/binding-darwin-arm64@0.145.0': + optional: true + + '@oxc-transform-react/binding-darwin-x64@0.145.0': + optional: true + + '@oxc-transform-react/binding-freebsd-x64@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-arm-gnueabihf@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-arm-musleabihf@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-arm64-gnu@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-arm64-musl@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-ppc64-gnu@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-riscv64-gnu@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-riscv64-musl@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-s390x-gnu@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-x64-gnu@0.145.0': + optional: true + + '@oxc-transform-react/binding-linux-x64-musl@0.145.0': + optional: true + + '@oxc-transform-react/binding-openharmony-arm64@0.145.0': + optional: true + + '@oxc-transform-react/binding-win32-arm64-msvc@0.145.0': + optional: true + + '@oxc-transform-react/binding-win32-ia32-msvc@0.145.0': + optional: true + + '@oxc-transform-react/binding-win32-x64-msvc@0.145.0': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.1.1(oxc-transform-react@0.145.0)(vite@8.3.2)': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.2 + optionalDependencies: + oxc-transform-react: 0.145.0 + + '@xstate/react@6.1.0(@types/react@19.3.0)(react@19.3.0)(xstate@5.33.2)': + dependencies: + react: 19.3.0 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.3.0)(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + xstate: 5.33.2 + transitivePeerDependencies: + - '@types/react' + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + nanoid@3.3.19: {} + + oxc-transform-react@0.145.0: + optionalDependencies: + '@oxc-transform-react/binding-android-arm-eabi': 0.145.0 + '@oxc-transform-react/binding-android-arm64': 0.145.0 + '@oxc-transform-react/binding-darwin-arm64': 0.145.0 + '@oxc-transform-react/binding-darwin-x64': 0.145.0 + '@oxc-transform-react/binding-freebsd-x64': 0.145.0 + '@oxc-transform-react/binding-linux-arm-gnueabihf': 0.145.0 + '@oxc-transform-react/binding-linux-arm-musleabihf': 0.145.0 + '@oxc-transform-react/binding-linux-arm64-gnu': 0.145.0 + '@oxc-transform-react/binding-linux-arm64-musl': 0.145.0 + '@oxc-transform-react/binding-linux-ppc64-gnu': 0.145.0 + '@oxc-transform-react/binding-linux-riscv64-gnu': 0.145.0 + '@oxc-transform-react/binding-linux-riscv64-musl': 0.145.0 + '@oxc-transform-react/binding-linux-s390x-gnu': 0.145.0 + '@oxc-transform-react/binding-linux-x64-gnu': 0.145.0 + '@oxc-transform-react/binding-linux-x64-musl': 0.145.0 + '@oxc-transform-react/binding-openharmony-arm64': 0.145.0 + '@oxc-transform-react/binding-win32-arm64-msvc': 0.145.0 + '@oxc-transform-react/binding-win32-ia32-msvc': 0.145.0 + '@oxc-transform-react/binding-win32-x64-msvc': 0.145.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + scheduler@0.28.0: {} + + source-map-js@1.2.2: {} + + sqzer@0.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@7.0.2: + 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 + + use-isomorphic-layout-effect@1.2.1(@types/react@19.3.0)(react@19.3.0): + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + + vite@8.3.2: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + xstate@5.33.2: {} diff --git a/site/index.html b/site/index.html deleted file mode 100644 index c9da8da..0000000 --- a/site/index.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - -sqzer - - - - - -
-

sqzer

-

Drop an image, get a smaller one that looks the same. It is encoded in this tab: nothing is uploaded.

-
- -
- - -
- -
- Quality - - -
- -
- -

Loading the encoder.

- - -
- - - - diff --git a/site/main.js b/site/main.js deleted file mode 100644 index 6e809c1..0000000 --- a/site/main.js +++ /dev/null @@ -1,372 +0,0 @@ -// The page: takes a file, keeps the worker in step with the file and the -// controls, and shows what comes back. Every call into `sqzer` is in `worker.js`. - -// The package's default `maxPixels`, for the one image the page draws itself. -const MAX_PIXELS = 24_000_000; -// How long an encode already running may take to finish before it is thrown away. -const PATIENCE_MS = 300; - -const $ = (id) => document.getElementById(id); -const form = $("options"); -const status = $("status"); - -let worker; -let codecs = []; -// The file on the page, as `take` makes it. -let input = null; -// The newest request, { job, options }, and the job the worker is busy with. -let wanted = null; -let running = null; -let started = 0; -let patience = 0; -let ids = 0; -let jobs = 0; -let shown = null; - -function spawn() { - const made = new Worker("worker.js", { type: "module" }); - // a worker that was ended may still have messages on their way - made.onmessage = ({ data }) => { - if (made === worker) on[data.type]?.(data); - }; - made.onerror = (e) => { - e.preventDefault(); - fail(`The encoder could not start: ${e.message ?? "this browser has no module workers"}.`); - }; - worker = made; -} - -// The package is synchronous: a search cannot be interrupted from outside. -// The only way to stop one is to end its worker and start another. -function restart() { - worker.terminate(); - spawn(); - running = null; - if (!input) return; - input.info = null; - if (input.preview === "asked") input.preview = "wanted"; - if (wanted) start(); -} - -function say(text) { - status.textContent = text; - status.classList.remove("failed"); -} - -function fail(text) { - // a browser's own message may end in a full stop already - status.textContent = text.replace(/\.+$/, "."); - status.classList.add("failed"); -} - -// `673 B`, `45.6 KB`, `1.2 MB`, as the command line prints them. -function bytes(n) { - if (n < 1000) return `${n} B`; - if (n < 1e6) return `${(n / 1e3).toFixed(1)} KB`; - return `${(n / 1e6).toFixed(1)} MB`; -} - -function change(before, after) { - const percent = Math.round((after / before - 1) * 100); - return `${percent > 0 ? "+" : ""}${percent}%`; -} - -const codec = (format) => codecs.find((c) => c.format === format); - -// What the controls say, as the options of the package. It validates them. -function options() { - const data = new FormData(form); - const out = {}; - const format = data.get("format"); - if (format !== "auto") out.format = format; - if (lossy(format)) { - const mode = data.get("mode"); - const value = data.get(mode); - if (value !== "") out[mode] = Number(value); - } - const width = data.get("width"); - if (width !== "") out.width = Number(width); - return out; -} - -// Whether quality means anything for `format`. An encoder that is lossless -// only (PNG, and WebP in this build) takes no target and no quality. -function lossy(format) { - return format === "auto" || codec(format)?.encoder?.lossy !== false; -} - -function isSvg(buffer) { - const head = new TextDecoder().decode(new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 4096))); - return //]/.test(head); -} - -async function load(blob) { - const url = URL.createObjectURL(blob); - try { - const img = new Image(); - img.src = url; - await img.decode(); - return img; - } finally { - URL.revokeObjectURL(url); - } -} - -// The input drawn by the browser, for what the package cannot decode: RGBA -// as `fromPixels` takes it. A vector image is drawn at `width`, sharp at any -// size; pixels are drawn at their own size and resized by the package. -async function draw(file, width) { - const img = await load(new Blob([file.bytes], { type: file.type })); - let { naturalWidth: w, naturalHeight: h } = img; - if (!(w && h)) throw new Error("the browser gives no size for this image"); - if (file.vector && width) { - h = Math.max(1, Math.round((h * width) / w)); - w = width; - } - if (w * h > MAX_PIXELS) { - throw new Error(`${w}x${h} is over the limit of ${MAX_PIXELS / 1e6} megapixels`); - } - const canvas = document.createElement("canvas"); - canvas.width = w; - canvas.height = h; - const context = canvas.getContext("2d", { willReadFrequently: true }); - context.drawImage(img, 0, 0, w, h); - return context.getImageData(0, 0, w, h); -} - -async function take(file) { - if (!file) return; - if (input?.url) URL.revokeObjectURL(input.url); - const buffer = await file.arrayBuffer(); - const vector = isSvg(buffer); - input = { - id: ++ids, - name: file.name || "image", - bytes: buffer, - size: buffer.byteLength, - type: vector ? "image/svg+xml" : file.type, - vector, - // drawn on the page instead of decoded in the worker - canvas: vector, - // what the worker holds: `decoded` fills it in - info: null, - // the width a vector image was last drawn at - drawn: null, - // null, "wanted", "asked" or "shown": the worker's rendering as "before" - preview: null, - url: "", - }; - input.url = URL.createObjectURL(new Blob([buffer], { type: input.type })); - show(null); - $("compare").style.width = ""; - $("compare").style.aspectRatio = ""; - $("before").src = input.url; - $("result").hidden = false; - // whatever the worker is encoding is for the image that just left - wanted = null; - if (running !== null) restart(); - sync(); -} - -// Bring the worker up to date with the file and the controls. -function sync() { - if (!input) return; - wanted = { job: ++jobs, options: options() }; - if (running === null) return start(); - clearTimeout(patience); - patience = setTimeout(restart, PATIENCE_MS); -} - -async function start() { - clearTimeout(patience); - const { job, options } = wanted; - const file = input; - running = job; - started = performance.now(); - // a width the package will refuse is not one to draw at - const width = file.vector && Number.isInteger(options.width) && options.width > 0 ? options.width : 0; - if (!file.info || (file.canvas && file.drawn !== width)) { - // `decoded` comes back here - say(`Reading ${file.name}.`); - file.info = null; - if (!file.canvas) { - worker.postMessage({ type: "decode", id: file.id, bytes: file.bytes }); - return; - } - try { - const { data, width: w, height: h } = await draw(file, width); - if (running !== job || input !== file) return; - file.drawn = width; - worker.postMessage({ type: "pixels", id: file.id, rgba: data.buffer, width: w, height: h }, [data.buffer]); - } catch (e) { - if (running !== job || input !== file) return; - running = null; - fail(file.unreadable ? `${file.unreadable}.` : `The browser could not read this image: ${e.message}.`); - } - return; - } - if (file.preview === "wanted") { - file.preview = "asked"; - worker.postMessage({ type: "preview", id: file.id }); - } - say("Encoding."); - worker.postMessage({ type: "encode", id: file.id, job, options }); -} - -// The stem of the input's name with the extension of the output. -function rename(name, extension) { - return `${name.replace(/\.[^./\\]*$/, "")}.${extension}`; -} - -// Put `result` on the page, or clear it with `null`. -function show(result) { - if (shown) URL.revokeObjectURL(shown); - shown = null; - const after = $("after"); - const download = $("download"); - after.hidden = !result; - if (!result) { - after.removeAttribute("src"); - download.hidden = true; - $("summary").textContent = ""; - return; - } - const { format, backend, bytes: data, outputWidth, outputHeight, width, height } = result; - const { mime, extension } = codec(format); - const name = rename(input.name, extension); - shown = URL.createObjectURL(new Blob([data], { type: mime })); - after.src = shown; - $("compare").style.aspectRatio = `${outputWidth} / ${outputHeight}`; - $("compare").style.width = `${outputWidth}px`; - download.href = shown; - download.download = name; - download.textContent = `Download ${name}`; - download.hidden = false; - - // the line the command line prints for a file, then what it leaves to `--json` - const how = result.lossless - ? "lossless" - : `q${result.quality}${result.score === undefined ? "" : ` s${result.score.toFixed(1)}`}`; - const lines = [ - `${input.name} -> ${name} ${bytes(input.size)} -> ${bytes(data.length)} ${change(input.size, data.length)} ${format} ${how}`, - `${width}x${height}${outputWidth === width && outputHeight === height ? "" : ` -> ${outputWidth}x${outputHeight}`} ${result.content} ${result.inputFormat ?? "drawn by the browser"} -> ${format} (${backend})`, - ]; - if (result.trials?.length) { - const reached = result.reached ? "reached" : "not reached"; - const count = result.trials.length === 1 ? "1 trial" : `${result.trials.length} trials`; - lines.push(`target ${result.target} ${reached} in ${count}: ${result.trials.map((t) => `q${t.quality} s${t.score.toFixed(1)}`).join(", ")}`); - } - $("summary").textContent = lines.join("\n"); -} - -const on = { - ready({ version, codecs: list }) { - codecs = list; - $("version").textContent = ` ${version}`; - const select = $("format"); - if (select.options.length === 1) { - for (const { format, encoder } of list) { - if (!encoder) continue; - const only = encoder.lossy ? "" : ", lossless"; - select.add(new Option(`${format.toUpperCase()}${only} (${encoder.backend})`, format)); - } - } - if (!input) say("Ready."); - }, - - decoded({ id, ...info }) { - if (id !== input?.id) return; - input.info = info; - if (!shown) { - $("compare").style.aspectRatio = `${info.width} / ${info.height}`; - $("compare").style.width = `${info.width}px`; - } - start(); - }, - - trial({ id, job, n, max, quality, score }) { - if (id !== input?.id || job !== wanted?.job) return; - say(`Encoding: trial ${n} of at most ${max}, quality ${quality} scores ${score.toFixed(1)}.`); - }, - - encoded({ id, job, result }) { - if (id !== input?.id) return; - running = null; - // the controls moved while this one ran - if (job !== wanted.job) return start(); - show(result); - say(`Done in ${((performance.now() - started) / 1000).toFixed(1)} s.`); - }, - - previewed({ id, bytes: data }) { - if (id !== input?.id) return; - URL.revokeObjectURL(input.url); - input.url = URL.createObjectURL(new Blob([data], { type: "image/webp" })); - input.preview = "shown"; - $("before").src = input.url; - }, - - error({ id, job, stage, kind, message }) { - if (stage === "load") return fail(`The encoder could not load: ${message}.`); - if (id !== input?.id) return; - if (stage === "preview") return; - // the package has no decoder and the worker's canvas had none either: - // the page's own `` is the last one to ask - if (stage === "decode" && kind === "DecoderUnavailable" && !input.canvas) { - input.canvas = true; - input.unreadable = message; - return start(); - } - running = null; - if (stage === "encode" && job !== wanted.job) return start(); - show(null); - fail(`${message}.`); - }, -}; - -// An image the browser cannot show itself, a TIFF say, is shown as the worker decoded it. -$("before").addEventListener("error", () => { - if (!input || input.preview) return; - input.preview = "wanted"; - if (input.info) { - input.preview = "asked"; - worker.postMessage({ type: "preview", id: input.id }); - } -}); - -$("file").addEventListener("change", (e) => take(e.target.files[0])); - -addEventListener("paste", (e) => { - const file = e.clipboardData?.files[0]; - if (!file) return; - e.preventDefault(); - take(file); -}); - -addEventListener("dragover", (e) => { - e.preventDefault(); - document.body.classList.add("dragging"); -}); -addEventListener("dragleave", (e) => { - // leaving the window, not moving from one element to the next - if (!e.relatedTarget) document.body.classList.remove("dragging"); -}); -addEventListener("drop", (e) => { - e.preventDefault(); - document.body.classList.remove("dragging"); - take(e.dataTransfer.files[0]); -}); - -let typing = 0; -form.addEventListener("input", () => { - $("how").disabled = !lossy($("format").value); - clearTimeout(typing); - typing = setTimeout(sync, 250); -}); -form.addEventListener("submit", (e) => e.preventDefault()); - -$("split").addEventListener("input", (e) => { - $("compare").style.setProperty("--split", `${e.target.value}%`); -}); - -spawn(); diff --git a/site/package.json b/site/package.json deleted file mode 100644 index b812c5f..0000000 --- a/site/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "sqzer.dev", - "private": true, - "description": "The page at sqzer.dev. `worker.js` reads the version of `sqzer` from here and imports it from jsDelivr.", - "license": "MIT OR Apache-2.0", - "dependencies": { - "sqzer": "0.3.0" - } -} diff --git a/site/selftest/checks.js b/site/selftest/checks.js deleted file mode 100644 index c9c784b..0000000 --- a/site/selftest/checks.js +++ /dev/null @@ -1,151 +0,0 @@ -// What `decodeAny` does in this browser, on the page and in a worker. The -// package's own tests run in Node, which has no canvas, so the canvas path -// of `decodeAny` is only ever checked here, by opening this page. -// -// ok as the package documents it -// note differs between browsers and is allowed to: read it, do not fix it -// FAIL a defect, in the package or in this page - -const where = typeof document === "undefined" ? "worker" : "page "; - -// As `../worker.js` loads it: the version of `../package.json`, from jsDelivr. -async function load() { - const manifest = await (await fetch(new URL("../package.json", import.meta.url))).json(); - const version = manifest.dependencies.sqzer; - const sqzer = await import(`https://cdn.jsdelivr.net/npm/sqzer@${version}/sqzer.js`); - await sqzer.default(); - return { sqzer, version }; -} - -async function fixture(name) { - const response = await fetch(new URL(`fixtures/${name}`, import.meta.url)); - return new Uint8Array(await response.arrayBuffer()); -} - -export async function run(say) { - const { sqzer, version } = await load(); - say(` ${where} sqzer ${version}, ${navigator.userAgent}`); - - // Count the bitmaps `decodeAny` makes and the ones it closes. A bitmap - // holds decoded pixels until it is closed, so the two must agree. - const bitmap = globalThis.createImageBitmap; - let made = 0; - let closed = 0; - globalThis.createImageBitmap = async (...args) => { - const result = await bitmap(...args); - made += 1; - return result; - }; - const close = ImageBitmap.prototype.close; - ImageBitmap.prototype.close = function () { - closed += 1; - return close.call(this); - }; - - // The pixels of a decoded image: through a lossless PNG and back. - async function pixels(image) { - const { bytes } = image.encode({ format: "png" }); - const drawn = await bitmap(new Blob([bytes], { type: "image/png" })); - const context = new OffscreenCanvas(drawn.width, drawn.height).getContext("2d"); - context.drawImage(drawn, 0, 0); - const { data, width } = context.getImageData(0, 0, drawn.width, drawn.height); - close.call(drawn); - return (x, y) => [...data.subarray((y * width + x) * 4, (y * width + x) * 4 + 3)].join(","); - } - - // The fixtures all hold one 48 x 32 pattern, whose blue channel steps - // from 40 to 220 between columns 23 and 24. Drawn `scale` times larger - // from the vector, the step stays one pixel wide; scaled up from a 48 - // pixel bitmap, it smears. - async function sharp(image, scale) { - const at = await pixels(image); - const y = 5 * scale; - const left = at(24 * scale - 1, y).split(",")[2]; - const right = at(24 * scale, y).split(",")[2]; - return left === "40" && right === "220" ? null : `blue is ${left} then ${right} across the edge, not 40 then 220`; - } - - // One call of `decodeAny`, described: { image } or { kind, message }. - async function decode(bytes, options) { - try { - return { image: await sqzer.decodeAny(bytes, options) }; - } catch (e) { - return { kind: e?.kind ?? "not a SqzerError", message: String(e?.message ?? e) }; - } - } - - const line = (mark, name, detail) => say(`${mark.padEnd(6)} ${where} ${name}: ${detail}`); - const size = (image) => `${image.width}x${image.height}`; - - const jpeg = await fixture("pattern-rgb.jpg"); - const svg = await fixture("pattern-rgb.svg"); - const heic = await fixture("pattern-rgb.heic"); - - // 1. the package's own decoder comes first: no bitmap for a JPEG - { - const { image, kind, message } = await decode(jpeg); - if (!image) line("FAIL", "jpeg", `${kind}: ${message}`); - else { - const fine = image.format === "jpeg" && size(image) === "48x32" && made === 0; - line(fine ? "ok" : "FAIL", "jpeg", `${size(image)}, format ${image.format}, ${made} bitmaps made`); - image.free(); - } - } - - // 2. SVG at its own size - let svgWorks = false; - { - const { image, kind, message } = await decode(svg); - if (image) { - svgWorks = true; - const blur = size(image) === "48x32" && image.format === "svg" ? await sharp(image, 1) : "wrong size or format"; - line(blur ? "FAIL" : "ok", "svg", `${size(image)}, format ${image.format}${blur ? `, ${blur}` : ""}`); - image.free(); - } else if (kind === "DecoderUnavailable" && where === "worker") { - line("note", "svg", `not in a worker here: ${message}`); - } else { - line("FAIL", "svg", `${kind}: ${message}`); - } - } - - // 3. SVG drawn to fit a width: on the page from the vector, in a worker from a bitmap - if (svgWorks) { - const { image, kind, message } = await decode(svg, { width: 480 }); - if (!image) line("FAIL", "svg at width 480", `${kind}: ${message}`); - else { - const blur = size(image) === "480x320" ? await sharp(image, 10) : "wrong size"; - if (!blur) line("ok", "svg at width 480", `${size(image)}, sharp`); - else if (where === "worker" && size(image) === "480x320") line("note", "svg at width 480", `${size(image)}, scaled from a bitmap: ${blur}`); - else line("FAIL", "svg at width 480", `${size(image)}, ${blur}`); - image.free(); - } - } - - // 4. an image over the limit is refused, and its bitmap closed - if (svgWorks) { - const { image, kind, message } = await decode(svg, { maxPixels: 1000 }); - image?.free(); - line(kind === "TooLarge" ? "ok" : "FAIL", "svg over maxPixels", image ? "was decoded" : `${kind}: ${message}`); - } - - // 5. HEIC: Safari decodes it, nothing else does - { - const before = made; - const { image, kind, message } = await decode(heic); - if (image) { - const right = size(image) === "48x32" && image.format === "heic"; - line(right ? "ok" : "FAIL", "heic", `${size(image)}, format ${image.format}, ${made - before} bitmaps made`); - image.free(); - const refused = await decode(heic, { maxPixels: 1000 }); - refused.image?.free(); - line(refused.kind === "TooLarge" ? "ok" : "FAIL", "heic over maxPixels", refused.image ? "was decoded" : `${refused.kind}: ${refused.message}`); - } else if (kind === "DecoderUnavailable") { - line("note", "heic", `not in this browser: ${message}`); - } else { - line("FAIL", "heic", `${kind}: ${message}`); - } - } - - // 6. every bitmap made was closed, the refused ones included - line(made === closed ? "ok" : "FAIL", "bitmaps", `${made} made, ${closed} closed`); -} diff --git a/site/selftest/fixtures/pattern-rgb.heic b/site/selftest/fixtures/pattern-rgb.heic deleted file mode 100644 index 21ebd0d40c74243e176a0a83513673a7fb8e4ba3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 769 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>UfiW{TwImTF4g`uBDLF+DCIdr3W^xIP z4Ws!AGD~v797Z6il$nzcHqym`fe{FSfUy@s16d%=%9)vYKubUpKr9O6q%uKy8D+_a zAm+==f}#Q-eJZmcIUh)u0VSLnnL$DjAoqdc&)*+E7#R4O6@bP_I56@s{{IhBz|1fK zL;#W8E+z*>prn=)BUmev%mRf*)@iSvEZez*Bf~%eq!>&nDgl+VxiB6qKF|rYM@-X38WdAIXF0glz3)A zZX$>e1i)ZnX=Gw)2C`~$Qxbt;!dRfexSp+|Uv16bwjbX<&Qx6WZio2+`-Zvo`{MGP zr%b=UwddFKM-N>eUP^Rgxjvs``dNXPD_@fJ&wBA!e?H&sSL?LyQncyO&qB{WzB>PK z`MoAl^W#(3eO%U@_WiA2tJ`ED+xa)`HQwCm{{Kg4SyFcXeAbsd3znUcs(Elf?}FmA zRC%{Wm-l}C`1wgz;cw%(w3bE3k2#C)uXb4_aU-H9xnQ1`@{j(sdi@TLKGuT2cN{p@ z{p}0hqIk+;!KQ!bPuZ)wIPY8hZ`td0;fxN=Mh)qc8EyZ3*ZIv7(AE@sYI0_o{k-M& zU*mYUD|3}G1?=}Xe^=#YTsXU5ld)Qi zVbRl8LFHrD4}Fa{RXw~`!@VYC&l9h}f>-RClKwN#ehCx%6+8c;{=HsZAuG8g?SPDw z|A!M7H2q5x`}wryO2Pg$6aEGE^D(mRumAWXee#KkzkV)%Sb5?1z6nK~ciG$j0RXoK B36uZ; diff --git a/site/selftest/fixtures/pattern-rgb.jpg b/site/selftest/fixtures/pattern-rgb.jpg deleted file mode 100644 index 0a3fc24a2ea4b6dcecaa85435ab96423b581de57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 673 zcmex=KbJlK`^-Q1$a(DOH)1?B3B~ArX8pE^1tfU|2CR zyU8XIH-7X_}^P!F^~On`wI$#f8ti5X}X2hdhV4yFih#snSKh7Dp&2e31N zMo0<*wV=3>fsKXT& - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/site/selftest/index.html b/site/selftest/index.html deleted file mode 100644 index 2b193b4..0000000 --- a/site/selftest/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - -sqzer: decodeAny in this browser - - -

decodeAny in this browser

-

What the canvas path of the sqzer package does here, on the page and in a worker. ok is as documented, note differs between browsers and may, FAIL is a defect: report it with the text below.

-

-
-
-
diff --git a/site/selftest/worker.js b/site/selftest/worker.js
deleted file mode 100644
index ab0bc4b..0000000
--- a/site/selftest/worker.js
+++ /dev/null
@@ -1,6 +0,0 @@
-// The checks of `checks.js` again, inside a worker.
-import { run } from "./checks.js";
-
-run((text) => postMessage(text))
-  .catch((e) => postMessage(`FAIL   worker  the checks did not run: ${e?.message ?? e}`))
-  .then(() => postMessage(null));
diff --git a/site/worker.js b/site/worker.js
deleted file mode 100644
index 4471a05..0000000
--- a/site/worker.js
+++ /dev/null
@@ -1,77 +0,0 @@
-// The worker: every call into the `sqzer` package happens here, so a search
-// that takes seconds never blocks the page. One image is held at a time.
-//
-// in   { type: "decode", id, bytes }                  a file, as an ArrayBuffer
-//      { type: "pixels", id, rgba, width, height }    RGBA the page drew itself
-//      { type: "encode", id, job, options }           options of the package
-//      { type: "preview", id, options }               the input as the browser can show it
-// out  { type: "ready", version, codecs }
-//      { type: "decoded", id, width, height, format, alpha, animated }
-//      { type: "trial", id, job, n, max, quality, score }
-//      { type: "encoded", id, job, result }
-//      { type: "previewed", id, bytes }
-//      { type: "error", id, job, stage, kind, message, availableIn }
-
-// `package.json` is the one place the version is written, and the place
-// Dependabot bumps it. Nothing is installed: the package comes from jsDelivr.
-async function load() {
-  const manifest = await fetch(new URL("package.json", import.meta.url));
-  if (!manifest.ok) throw new Error(`package.json: HTTP ${manifest.status}`);
-  const version = (await manifest.json()).dependencies.sqzer;
-  const sqzer = await import(`https://cdn.jsdelivr.net/npm/sqzer@${version}/sqzer.js`);
-  await sqzer.default();
-  postMessage({ type: "ready", version, codecs: sqzer.codecs() });
-  return sqzer;
-}
-
-const loading = load();
-let image = null;
-let held = 0;
-
-function hold(id, decoded) {
-  image?.free();
-  image = decoded;
-  held = id;
-  const { width, height, format, alpha, animated } = decoded;
-  postMessage({ type: "decoded", id, width, height, format, alpha, animated });
-}
-
-async function handle(message) {
-  const sqzer = await loading;
-  const { type, id, job } = message;
-  // a message for an image that failed to decode, or was replaced since
-  if ((type === "encode" || type === "preview") && id !== held) return;
-  try {
-    if (type === "decode") {
-      hold(id, await sqzer.decodeAny(new Uint8Array(message.bytes)));
-    } else if (type === "pixels") {
-      const { rgba, width, height } = message;
-      hold(id, sqzer.fromPixels(new Uint8ClampedArray(rgba), width, height));
-    } else if (type === "encode") {
-      const result = image.encode({
-        ...message.options,
-        onTrial: (trial) => postMessage({ type: "trial", id, job, ...trial }),
-      });
-      postMessage({ type: "encoded", id, job, result }, [result.bytes.buffer]);
-    } else if (type === "preview") {
-      // lossless, so what the page shows as "before" is what the encoder saw
-      const { bytes } = image.encode({ ...message.options, format: "webp", lossless: true });
-      postMessage({ type: "previewed", id, bytes }, [bytes.buffer]);
-    }
-  } catch (e) {
-    const { kind = "Other", message: text = String(e), availableIn } = e ?? {};
-    postMessage({ type: "error", id, job, stage: type, kind, message: text, availableIn });
-  }
-}
-
-// One message at a time, in order: `decodeAny` is asynchronous, and an
-// encode must not start before the decode it follows has finished.
-let queue = Promise.resolve();
-onmessage = ({ data }) => {
-  queue = queue.then(() => handle(data));
-};
-
-// A failed load answers every message with a rejection nobody sees, so say it once.
-loading.catch((e) => {
-  postMessage({ type: "error", stage: "load", kind: "Other", message: String(e?.message ?? e) });
-});
diff --git a/src/app/main.tsx b/src/app/main.tsx
new file mode 100644
index 0000000..f3ffeb6
--- /dev/null
+++ b/src/app/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { CompressPage, SearchProvider } from '@/pages/compress';
+import './style.css';
+
+const root = document.getElementById('root');
+if (!root) throw new Error('index.html has no #root');
+
+createRoot(root).render(
+  
+    
+      
+    
+  ,
+);
diff --git a/site/style.css b/src/app/style.css
similarity index 81%
rename from site/style.css
rename to src/app/style.css
index af37e25..a8571be 100644
--- a/site/style.css
+++ b/src/app/style.css
@@ -26,7 +26,7 @@ code, pre, #status { font-family: ui-monospace, monospace; }
   cursor: pointer;
 }
 #drop span { display: block; margin-bottom: 0.5rem; }
-body.dragging #drop { border-color: Highlight; }
+#drop[data-dragging] { border-color: Highlight; }
 
 #options {
   display: flex;
@@ -47,12 +47,15 @@ body.dragging #drop { border-color: Highlight; }
 #options fieldset:disabled { color: GrayText; }
 
 #status { min-height: 1.5em; }
-#status.failed { color: #b00020; color: light-dark(#b00020, #ff8a80); }
+#status[data-failed] { color: #b00020; color: light-dark(#b00020, #ff8a80); }
 
-/* Two pictures in one cell, the second clipped at the handle. */
+/* Two pictures in one cell, the second clipped at the handle. The size is
+   the image's, once it is known. */
 #compare {
   display: grid;
+  width: var(--width, auto);
   max-width: 100%;
+  aspect-ratio: var(--ratio, auto);
   margin: 0 auto;
   background: repeating-conic-gradient(GrayText 0 25%, Canvas 0 50%) 0 0 / 1rem 1rem;
 }
diff --git a/src/pages/compress/index.ts b/src/pages/compress/index.ts
new file mode 100644
index 0000000..6953c6e
--- /dev/null
+++ b/src/pages/compress/index.ts
@@ -0,0 +1,2 @@
+export { SearchProvider } from './model/context';
+export { CompressPage } from './ui/compress-page';
diff --git a/src/pages/compress/lib/codec.ts b/src/pages/compress/lib/codec.ts
new file mode 100644
index 0000000..2bf45c3
--- /dev/null
+++ b/src/pages/compress/lib/codec.ts
@@ -0,0 +1,16 @@
+import type { Codec } from '@/shared/api';
+
+/** The entry of `codecs()` for `format`. The package lists every format it reads or writes. */
+export function codecOf(codecs: Codec[], format: string): Codec {
+  const codec = codecs.find((entry) => entry.format === format);
+  if (!codec) throw new Error(`\`codecs()\` has no entry for ${format}`);
+  return codec;
+}
+
+/**
+ * Whether quality means anything for `format`. An encoder that is lossless
+ * only (PNG, and WebP in this build) takes no target and no quality.
+ */
+export function isLossy(codecs: Codec[], format: string) {
+  return format === 'auto' || codecs.find((entry) => entry.format === format)?.encoder?.lossy !== false;
+}
diff --git a/src/pages/compress/lib/draw.ts b/src/pages/compress/lib/draw.ts
new file mode 100644
index 0000000..8baf2c0
--- /dev/null
+++ b/src/pages/compress/lib/draw.ts
@@ -0,0 +1,41 @@
+import type { Picked } from '../model/picked';
+
+// The package's default `maxPixels`, for the one image the page draws itself.
+const MAX_PIXELS = 24_000_000;
+
+async function load(blob: Blob) {
+  const url = URL.createObjectURL(blob);
+  try {
+    const img = new Image();
+    img.src = url;
+    await img.decode();
+    return img;
+  } finally {
+    URL.revokeObjectURL(url);
+  }
+}
+
+/**
+ * The image drawn by the browser, for what the package cannot decode: RGBA
+ * as `fromPixels` takes it. A vector image is drawn at `width`, sharp at any
+ * size; pixels are drawn at their own size and resized by the package.
+ */
+export async function draw(image: Picked, width: number): Promise {
+  const img = await load(image.blob);
+  let { naturalWidth: w, naturalHeight: h } = img;
+  if (!(w && h)) throw new Error('the browser gives no size for this image');
+  if (image.vector && width) {
+    h = Math.max(1, Math.round((h * width) / w));
+    w = width;
+  }
+  if (w * h > MAX_PIXELS) {
+    throw new Error(`${w}x${h} is over the limit of ${MAX_PIXELS / 1e6} megapixels`);
+  }
+  const canvas = document.createElement('canvas');
+  canvas.width = w;
+  canvas.height = h;
+  const context = canvas.getContext('2d', { willReadFrequently: true });
+  if (!context) throw new Error('the browser gives no canvas to draw on');
+  context.drawImage(img, 0, 0, w, h);
+  return context.getImageData(0, 0, w, h);
+}
diff --git a/src/pages/compress/lib/options-of.ts b/src/pages/compress/lib/options-of.ts
new file mode 100644
index 0000000..e884de2
--- /dev/null
+++ b/src/pages/compress/lib/options-of.ts
@@ -0,0 +1,23 @@
+import type { Codec, EncodeOptions } from '@/shared/api';
+import { isLossy } from './codec';
+
+/** What the controls say, as the options of the package. It validates them. */
+export function optionsOf(form: HTMLFormElement, codecs: Codec[]): EncodeOptions {
+  const data = new FormData(form);
+  const field = (name: string) => {
+    const value = data.get(name);
+    return typeof value === 'string' ? value : '';
+  };
+  const options: EncodeOptions = {};
+  const format = field('format');
+  // the list comes from `codecs()`, so the package knows every format on it
+  if (format !== 'auto') options.format = format as EncodeOptions['format'];
+  if (isLossy(codecs, format)) {
+    const mode = field('mode');
+    const value = field(mode);
+    if ((mode === 'target' || mode === 'quality') && value !== '') options[mode] = Number(value);
+  }
+  const width = field('width');
+  if (width !== '') options.width = Number(width);
+  return options;
+}
diff --git a/src/pages/compress/lib/summary.ts b/src/pages/compress/lib/summary.ts
new file mode 100644
index 0000000..7f07d11
--- /dev/null
+++ b/src/pages/compress/lib/summary.ts
@@ -0,0 +1,33 @@
+import type { EncodeResult } from '../model/encode-result';
+
+// `673 B`, `45.6 KB`, `1.2 MB`, as the command line prints them.
+function bytes(n: number) {
+  if (n < 1000) return `${n} B`;
+  if (n < 1e6) return `${(n / 1e3).toFixed(1)} KB`;
+  return `${(n / 1e6).toFixed(1)} MB`;
+}
+
+function change(before: number, after: number) {
+  const percent = Math.round((after / before - 1) * 100);
+  return `${percent > 0 ? '+' : ''}${percent}%`;
+}
+
+/** The line the command line prints for a file, then what it leaves to `--json`. */
+export function summarize(source: { name: string; size: number }, { output, file }: EncodeResult) {
+  const { format, backend, width, height, outputWidth, outputHeight } = output;
+  const how = output.lossless
+    ? 'lossless'
+    : `q${output.quality}${output.score === undefined ? '' : ` s${output.score.toFixed(1)}`}`;
+  const resized = outputWidth === width && outputHeight === height ? '' : ` -> ${outputWidth}x${outputHeight}`;
+  const lines = [
+    `${source.name} -> ${file.name}  ${bytes(source.size)} -> ${bytes(file.size)}  ${change(source.size, file.size)}  ${format} ${how}`,
+    `${width}x${height}${resized}  ${output.content}  ${output.inputFormat ?? 'drawn by the browser'} -> ${format} (${backend})`,
+  ];
+  if (output.trials?.length) {
+    const reached = output.reached ? 'reached' : 'not reached';
+    const count = output.trials.length === 1 ? '1 trial' : `${output.trials.length} trials`;
+    const trials = output.trials.map((trial) => `q${trial.quality} s${trial.score.toFixed(1)}`).join(', ');
+    lines.push(`target ${output.target} ${reached} in ${count}: ${trials}`);
+  }
+  return lines.join('\n');
+}
diff --git a/src/pages/compress/model/context.ts b/src/pages/compress/model/context.ts
new file mode 100644
index 0000000..1d74ae8
--- /dev/null
+++ b/src/pages/compress/model/context.ts
@@ -0,0 +1,11 @@
+import { createActorContext } from '@xstate/react';
+import { searchMachine } from './machine';
+
+const SearchContext = createActorContext(searchMachine);
+
+/** Runs the search machine for the page under it. */
+export const SearchProvider = SearchContext.Provider;
+/** A value read from the machine's snapshot. */
+export const useSearch = SearchContext.useSelector;
+/** The machine itself, to send it events. */
+export const useSearchRef = SearchContext.useActorRef;
diff --git a/src/pages/compress/model/encode-result.ts b/src/pages/compress/model/encode-result.ts
new file mode 100644
index 0000000..3037d39
--- /dev/null
+++ b/src/pages/compress/model/encode-result.ts
@@ -0,0 +1,15 @@
+import type { Output } from '@/shared/api';
+
+export type EncodeResult = {
+  /** What the package returned: the bytes and how they were made. */
+  output: Output;
+  /** The bytes under the name and the type they are downloaded with. */
+  file: File;
+  /** How long the encode took. */
+  seconds: number;
+};
+
+/** The stem of `name` with the extension of the output. */
+export function rename(name: string, extension: string) {
+  return `${name.replace(/\.[^./\\]*$/, '')}.${extension}`;
+}
diff --git a/src/pages/compress/model/encoder.ts b/src/pages/compress/model/encoder.ts
new file mode 100644
index 0000000..07c277d
--- /dev/null
+++ b/src/pages/compress/model/encoder.ts
@@ -0,0 +1,116 @@
+import { fromCallback } from 'xstate';
+import { startEncoder, type Codec, type Decoded, type EncodeOptions, type TrialProgress } from '@/shared/api';
+import { codecOf } from '../lib/codec';
+import { draw } from '../lib/draw';
+import { rename, type EncodeResult } from './encode-result';
+import type { Picked } from './picked';
+
+/** What the machine asks of the actor. */
+export type EncoderCommand =
+  | {
+      /** Give the worker the image: decoded there, or drawn here at `width` when it is `onPage`. */
+      type: 'read';
+      image: Picked;
+      onPage: boolean;
+      width: number;
+      /** The package's words for having no decoder for the image, when it said so. */
+      unreadable: string | null;
+    }
+  | { type: 'encode'; options: EncodeOptions }
+  /** The image the worker holds, as a file every browser can show. */
+  | { type: 'preview' };
+
+/** What the actor tells the machine. */
+export type EncoderEvent =
+  | { type: 'ready'; version: string; codecs: Codec[] }
+  | { type: 'unreadable'; message: string }
+  /** `drawnWidth` is the width an image drawn on the page was drawn at, null for one the worker decoded. */
+  | { type: 'decoded'; decoded: Decoded; drawnWidth: number | null }
+  | { type: 'trial'; trial: TrialProgress }
+  | { type: 'previewed'; preview: Blob }
+  | { type: 'done'; result: EncodeResult }
+  /** `broken` when the worker itself failed, and has to be replaced before anything else is asked. */
+  | { type: 'failed'; message: string; broken: boolean };
+
+const reason = (thrown: unknown) => (thrown instanceof Error ? thrown.message : String(thrown));
+
+/**
+ * The worker as an actor. It lives as long as the state that invoked it, and
+ * stopping it ends the worker: the only way a search is cancelled.
+ */
+export const encoder = fromCallback(({ sendBack, receive }) => {
+  const tell = (event: EncoderEvent) => sendBack(event);
+  // a browser's own message may end in a full stop already
+  const fail = (text: string, broken = false) =>
+    tell({ type: 'failed', message: text.replace(/\.+$/, '.'), broken });
+
+  let stopped = false;
+  let codecs: Codec[] = [];
+  // The image the worker was last given, and the number its messages carry.
+  let id = 0;
+  let read: Extract | null = null;
+  let startedAt = 0;
+
+  const worker = startEncoder({
+    onMessage(reply) {
+      if (reply.type === 'ready') {
+        codecs = reply.codecs;
+        tell(reply);
+      } else if (reply.type === 'error' && reply.stage === 'start') {
+        fail(`The encoder could not start: ${reply.message}.`, true);
+      } else if (reply.type === 'error' && reply.stage === 'load') {
+        fail(`The encoder could not load: ${reply.message}.`, true);
+      } else if (reply.id !== id || !read) {
+        // about an image that was replaced since
+      } else if (reply.type === 'error') {
+        if (reply.stage === 'preview') return;
+        if (reply.stage === 'decode' && reply.kind === 'DecoderUnavailable') {
+          // the package has no decoder and the worker's canvas had none
+          // either. the machine asks again, for the page's own ``
+          tell({ type: 'unreadable', message: reply.message });
+        } else fail(`${reply.message}.`);
+      } else if (reply.type === 'decoded') {
+        tell({ type: 'decoded', decoded: reply.image, drawnWidth: read.onPage ? read.width : null });
+      } else if (reply.type === 'trial') {
+        tell(reply);
+      } else if (reply.type === 'previewed') {
+        tell({ type: 'previewed', preview: new Blob([reply.bytes], { type: 'image/webp' }) });
+      } else {
+        const output = reply.result;
+        const { mime, extension } = codecOf(codecs, output.format);
+        const file = new File([output.bytes], rename(read.image.name, extension), { type: mime });
+        tell({ type: 'done', result: { output, file, seconds: (performance.now() - startedAt) / 1000 } });
+      }
+    },
+  });
+
+  async function drawOnPage({ image, width, unreadable }: NonNullable, drawing: number) {
+    const current = () => !stopped && drawing === id;
+    try {
+      const { data, width: w, height: h } = await draw(image, width);
+      if (current()) worker.send({ type: 'pixels', id, rgba: data.buffer, width: w, height: h }, [data.buffer]);
+    } catch (thrown) {
+      if (!current()) return;
+      fail(unreadable ? `${unreadable}.` : `The browser could not read this image: ${reason(thrown)}.`);
+    }
+  }
+
+  receive((command) => {
+    if (command.type === 'read') {
+      id += 1;
+      read = command;
+      if (command.onPage) void drawOnPage(command, id);
+      else worker.send({ type: 'decode', id, bytes: command.image.bytes });
+    } else if (command.type === 'encode') {
+      startedAt = performance.now();
+      worker.send({ type: 'encode', id, options: command.options });
+    } else {
+      worker.send({ type: 'preview', id });
+    }
+  });
+
+  return () => {
+    stopped = true;
+    worker.terminate();
+  };
+});
diff --git a/src/pages/compress/model/machine.ts b/src/pages/compress/model/machine.ts
new file mode 100644
index 0000000..31ea00d
--- /dev/null
+++ b/src/pages/compress/model/machine.ts
@@ -0,0 +1,226 @@
+import { and, assertEvent, assign, cancel, enqueueActions, not, raise, setup } from 'xstate';
+import type { Codec, Decoded, EncodeOptions, TrialProgress } from '@/shared/api';
+import type { EncodeResult } from './encode-result';
+import { encoder, type EncoderCommand, type EncoderEvent } from './encoder';
+import type { Picked } from './picked';
+
+// How long the worker may take to finish what it is doing, once the controls
+// have moved on, before it is ended.
+const PATIENCE_MS = 300;
+
+type Context = {
+  /** The version of `sqzer`, once a worker has loaded it. */
+  version: string | null;
+  /** What `codecs()` lists. Empty until then. */
+  codecs: Codec[];
+  /** The file on the page. */
+  image: Picked | null;
+  /** What the controls said when the newest search was asked for. */
+  options: EncodeOptions;
+  /** The package's words for having no decoder for the image. The page draws it from then on. */
+  unreadable: string | null;
+  /** The worker holds the image, decoded. */
+  held: boolean;
+  /** The width an image drawn on the page was last drawn at. */
+  drawnWidth: number | null;
+  /** The controls moved while the worker was busy with what they said before. */
+  outdated: boolean;
+  /** The browser cannot show the image itself, a TIFF say. */
+  unshowable: boolean;
+  /** The worker was asked for the image in a format every browser shows. */
+  previewAsked: boolean;
+  preview: Blob | null;
+  decoded: Decoded | null;
+  /** The trials of the running search, as they land. */
+  trials: TrialProgress[];
+  result: EncodeResult | null;
+  error: string | null;
+  /** The worker itself failed. The next search starts another. */
+  broken: boolean;
+};
+
+type SearchEvent =
+  | { type: 'picked'; image: Picked; options: EncodeOptions }
+  | { type: 'options'; options: EncodeOptions }
+  | { type: 'unshowable' }
+  /** End the worker, start another and search again. */
+  | { type: 'restart' }
+  | EncoderEvent;
+
+/** Drawn by the page's own `` instead of decoded in the worker. */
+const onPage = ({ image, unreadable }: Context) => Boolean(image?.vector) || unreadable !== null;
+
+/** The width to draw a vector image at. A width the package will refuse is not one to draw at. */
+function drawWidth({ image, options: { width = 0 } }: Context) {
+  return image?.vector && Number.isInteger(width) && width > 0 ? width : 0;
+}
+
+/**
+ * The search (ADR-0002 D10, ADR-0004). The worker lives as long as `open`
+ * and keeps the decoded image between searches, so a change of the controls
+ * only pays for the encode. A search cannot be interrupted: `restart`
+ * re-enters `open`, which ends the worker and starts another, and that is
+ * the only way an encode is cancelled.
+ */
+export const searchMachine = setup({
+  types: { context: {} as Context, events: {} as SearchEvent },
+  actors: { encoder },
+  guards: {
+    hasImage: ({ context }) => context.image !== null,
+    showable: ({ context }) => !context.unshowable,
+    broken: ({ context }) => context.broken,
+    outdated: ({ context }) => context.outdated,
+    needsReading: ({ context }) =>
+      !context.held || (onPage(context) && context.drawnWidth !== drawWidth(context)),
+  },
+  actions: {
+    takeImage: assign(({ event }) => {
+      assertEvent(event, 'picked');
+      return {
+        image: event.image,
+        options: event.options,
+        unreadable: null,
+        held: false,
+        drawnWidth: null,
+        unshowable: false,
+        previewAsked: false,
+        preview: null,
+        decoded: null,
+        result: null,
+      };
+    }),
+    takeOptions: assign(({ event }) => {
+      assertEvent(event, 'options');
+      return { options: event.options };
+    }),
+    read: enqueueActions(({ context, enqueue }) => {
+      const { image, unreadable } = context;
+      if (!image) return;
+      const command: EncoderCommand = {
+        type: 'read',
+        image,
+        onPage: onPage(context),
+        width: drawWidth(context),
+        unreadable,
+      };
+      enqueue.sendTo('encoder', command);
+    }),
+    encode: enqueueActions(({ context, enqueue }) => {
+      const command: EncoderCommand = { type: 'encode', options: context.options };
+      enqueue.sendTo('encoder', command);
+    }),
+    // Only an image the worker holds can be asked for, and only once.
+    askForPreview: enqueueActions(({ context, enqueue }) => {
+      const { unshowable, preview, previewAsked, held } = context;
+      if (!unshowable || preview || previewAsked || !held) return;
+      const command: EncoderCommand = { type: 'preview' };
+      enqueue.sendTo('encoder', command);
+      enqueue.assign({ previewAsked: true });
+    }),
+    restart: raise({ type: 'restart' }),
+    // What the worker is doing gets a moment to finish, then the worker is ended.
+    losePatience: raise({ type: 'restart' }, { delay: PATIENCE_MS, id: 'patience' }),
+    keepPatience: cancel('patience'),
+  },
+}).createMachine({
+  id: 'search',
+  context: {
+    version: null,
+    codecs: [],
+    image: null,
+    options: {},
+    unreadable: null,
+    held: false,
+    drawnWidth: null,
+    outdated: false,
+    unshowable: false,
+    previewAsked: false,
+    preview: null,
+    decoded: null,
+    trials: [],
+    result: null,
+    error: null,
+    broken: false,
+  },
+  initial: 'open',
+  states: {
+    open: {
+      // a new worker holds nothing and was asked for nothing
+      entry: assign({ held: false, previewAsked: false, outdated: false, broken: false }),
+      invoke: { id: 'encoder', src: 'encoder' },
+      initial: 'empty',
+      on: {
+        restart: { target: '.searching', reenter: true },
+        ready: { actions: assign(({ event }) => ({ version: event.version, codecs: event.codecs })) },
+        previewed: { actions: assign(({ event }) => ({ preview: event.preview })) },
+        // the worker is idle: it takes the new image, or the new options for the one it holds
+        picked: [
+          { guard: 'broken', actions: ['takeImage', 'restart'] },
+          { target: '.searching', actions: 'takeImage' },
+        ],
+        options: [
+          { guard: and(['hasImage', 'broken']), actions: ['takeOptions', 'restart'] },
+          { guard: 'hasImage', target: '.searching', actions: 'takeOptions' },
+        ],
+        unshowable: { guard: 'showable', actions: [assign({ unshowable: true }), 'askForPreview'] },
+        failed: {
+          target: '.failed',
+          actions: assign(({ event }) => ({ error: event.message, result: null, broken: event.broken })),
+        },
+      },
+      states: {
+        empty: {},
+        searching: {
+          initial: 'starting',
+          exit: 'keepPatience',
+          on: {
+            // the worker is busy with the image that just left
+            picked: { actions: ['takeImage', 'restart'] },
+            options: { actions: ['takeOptions', assign({ outdated: true }), 'keepPatience', 'losePatience'] },
+            // the newest options get their run, whatever came of the ones before
+            failed: { guard: 'outdated', actions: 'restart' },
+          },
+          states: {
+            starting: {
+              always: [{ guard: 'needsReading', target: 'reading' }, { target: 'encoding' }],
+            },
+            reading: {
+              entry: 'read',
+              on: {
+                decoded: {
+                  target: 'starting',
+                  actions: [
+                    'keepPatience',
+                    assign(({ event }) => ({ decoded: event.decoded, held: true, drawnWidth: event.drawnWidth })),
+                  ],
+                },
+                // the worker's canvas had no decoder either: the page's own `` is the last one to ask
+                unreadable: {
+                  target: 'reading',
+                  reenter: true,
+                  actions: assign(({ event }) => ({ unreadable: event.message })),
+                },
+              },
+            },
+            encoding: {
+              entry: [assign({ trials: [], outdated: false }), 'askForPreview', 'encode'],
+              on: {
+                trial: {
+                  guard: not('outdated'),
+                  actions: assign(({ context, event }) => ({ trials: [...context.trials, event.trial] })),
+                },
+                done: [
+                  // the controls moved while this one ran
+                  { guard: 'outdated', target: 'starting', actions: 'keepPatience' },
+                  { target: '#search.open.result', actions: assign(({ event }) => ({ result: event.result })) },
+                ],
+              },
+            },
+          },
+        },
+        result: {},
+        failed: {},
+      },
+    },
+  },
+});
diff --git a/src/pages/compress/model/picked.ts b/src/pages/compress/model/picked.ts
new file mode 100644
index 0000000..90d2055
--- /dev/null
+++ b/src/pages/compress/model/picked.ts
@@ -0,0 +1,22 @@
+/** The file on the page. */
+export type Picked = {
+  name: string;
+  /** The file as it was picked: what the worker decodes. */
+  bytes: ArrayBuffer;
+  /** The same bytes for an ``, typed so the browser reads an SVG as one. */
+  blob: Blob;
+  /** Drawn on the page at the width asked for, sharp at any size. */
+  vector: boolean;
+};
+
+function isSvg(bytes: ArrayBuffer) {
+  const head = new TextDecoder().decode(new Uint8Array(bytes, 0, Math.min(bytes.byteLength, 4096)));
+  return //]/.test(head);
+}
+
+export async function pick(file: File): Promise {
+  const bytes = await file.arrayBuffer();
+  const vector = isSvg(bytes);
+  const blob = new Blob([bytes], { type: vector ? 'image/svg+xml' : file.type });
+  return { name: file.name || 'image', bytes, blob, vector };
+}
diff --git a/src/pages/compress/ui/comparison.tsx b/src/pages/compress/ui/comparison.tsx
new file mode 100644
index 0000000..c5d043b
--- /dev/null
+++ b/src/pages/compress/ui/comparison.tsx
@@ -0,0 +1,48 @@
+import { useState, type CSSProperties } from 'react';
+import { useSearch, useSearchRef } from '../model/context';
+import { useObjectUrl } from '@/shared/lib/object-url';
+
+/** The image as it was dropped and as it was encoded, in one cell, split at the handle. */
+export function Comparison() {
+  const search = useSearchRef();
+  const image = useSearch((snapshot) => snapshot.context.image);
+  const preview = useSearch((snapshot) => snapshot.context.preview);
+  const decoded = useSearch((snapshot) => snapshot.context.decoded);
+  const result = useSearch((snapshot) => snapshot.context.result);
+  const [split, setSplit] = useState(50);
+
+  // an image the browser cannot show itself, a TIFF say, is shown as the worker decoded it
+  const before = useObjectUrl(preview ?? image?.blob ?? null, 'src');
+  const after = useObjectUrl(result?.file ?? null, 'src');
+
+  // the size of the output once there is one, of the input until then
+  const size = result
+    ? { width: result.output.outputWidth, height: result.output.outputHeight }
+    : decoded;
+  const properties = {
+    '--split': `${split}%`,
+    '--width': size ? `${size.width}px` : undefined,
+    '--ratio': size ? `${size.width} / ${size.height}` : undefined,
+  } as CSSProperties;
+
+  return (
+    
+ The image as it was dropped search.send({ type: 'unshowable' })} + /> + + setSplit(event.target.valueAsNumber)} + aria-label="Before on the left, after on the right" + /> +
+ ); +} diff --git a/src/pages/compress/ui/compress-page.tsx b/src/pages/compress/ui/compress-page.tsx new file mode 100644 index 0000000..66b3413 --- /dev/null +++ b/src/pages/compress/ui/compress-page.tsx @@ -0,0 +1,53 @@ +import { useRef } from 'react'; +import { optionsOf } from '../lib/options-of'; +import { useSearch, useSearchRef } from '../model/context'; +import { pick } from '../model/picked'; +import { Comparison } from './comparison'; +import { ControlPanel } from './control-panel'; +import { DropZone } from './drop-zone'; +import { ResultPanel } from './result-panel'; +import { SearchStatus } from './search-status'; + +export function CompressPage() { + const search = useSearchRef(); + const version = useSearch((snapshot) => snapshot.context.version); + const hasImage = useSearch((snapshot) => snapshot.context.image !== null); + const form = useRef(null); + + // What the controls say now. They are read when a search starts, not kept in step with it. + const options = () => (form.current ? optionsOf(form.current, search.getSnapshot().context.codecs) : {}); + + return ( + <> +
+

sqzer

+

+ Drop an image, get a smaller one that looks the same. It is encoded in this tab: nothing is uploaded. +

+
+ +
+ search.send({ type: 'picked', image: await pick(file), options: options() })} + /> + search.send({ type: 'options', options: options() })} /> + + +
+ + + + ); +} diff --git a/src/pages/compress/ui/control-panel.tsx b/src/pages/compress/ui/control-panel.tsx new file mode 100644 index 0000000..f6fb7de --- /dev/null +++ b/src/pages/compress/ui/control-panel.tsx @@ -0,0 +1,74 @@ +import { useRef, useState, type Ref } from 'react'; +import { isLossy } from '../lib/codec'; +import { useSearch } from '../model/context'; + +// How long the controls rest before a search starts with what they say. +const TYPING_MS = 250; + +/** The controls. `onChange` is called once they have rested; `optionsOf` reads the form of `ref`. */ +export function ControlPanel({ ref, onChange }: { ref: Ref; onChange: () => void }) { + const codecs = useSearch((snapshot) => snapshot.context.codecs); + const [format, setFormat] = useState('auto'); + const typing = useRef(0); + + return ( +
{ + clearTimeout(typing.current); + typing.current = window.setTimeout(onChange, TYPING_MS); + }} + onSubmit={(event) => event.preventDefault()} + > + +
+ Quality + + +
+ +
+ ); +} diff --git a/src/pages/compress/ui/drop-zone.tsx b/src/pages/compress/ui/drop-zone.tsx new file mode 100644 index 0000000..cbc1c80 --- /dev/null +++ b/src/pages/compress/ui/drop-zone.tsx @@ -0,0 +1,63 @@ +import { useEffect, useEffectEvent, useState } from 'react'; + +/** Where a file comes in: chosen, pasted, or dropped anywhere on the window. */ +export function DropZone({ onPick }: { onPick: (file: File) => void }) { + const [dragging, setDragging] = useState(false); + const pick = useEffectEvent(onPick); + + useEffect(() => { + const listeners = new AbortController(); + const { signal } = listeners; + addEventListener( + 'paste', + (event) => { + const file = event.clipboardData?.files[0]; + if (!file) return; + event.preventDefault(); + pick(file); + }, + { signal }, + ); + addEventListener( + 'dragover', + (event) => { + event.preventDefault(); + setDragging(true); + }, + { signal }, + ); + addEventListener( + 'dragleave', + (event) => { + // leaving the window, not moving from one element to the next + if (!event.relatedTarget) setDragging(false); + }, + { signal }, + ); + addEventListener( + 'drop', + (event) => { + event.preventDefault(); + setDragging(false); + const file = event.dataTransfer?.files[0]; + if (file) pick(file); + }, + { signal }, + ); + return () => listeners.abort(); + }, []); + + return ( + + ); +} diff --git a/src/pages/compress/ui/result-panel.tsx b/src/pages/compress/ui/result-panel.tsx new file mode 100644 index 0000000..e6ed27d --- /dev/null +++ b/src/pages/compress/ui/result-panel.tsx @@ -0,0 +1,22 @@ +import { useObjectUrl } from '@/shared/lib/object-url'; +import { summarize } from '../lib/summary'; +import { useSearch } from '../model/context'; + +/** What was made, as the command line would print it, and the file to save, hidden until there is one. */ +export function ResultPanel() { + const image = useSearch((snapshot) => snapshot.context.image); + const result = useSearch((snapshot) => snapshot.context.result); + const file = result?.file ?? null; + const download = useObjectUrl(file, 'href'); + + return ( + <> +
+        {image && result && summarize({ name: image.name, size: image.bytes.byteLength }, result)}
+      
+ + + ); +} diff --git a/src/pages/compress/ui/search-status.tsx b/src/pages/compress/ui/search-status.tsx new file mode 100644 index 0000000..7b8e781 --- /dev/null +++ b/src/pages/compress/ui/search-status.tsx @@ -0,0 +1,22 @@ +import { useSearch } from '../model/context'; + +/** One line on where the search is. A failure is the same line, marked. */ +export function SearchStatus() { + const failed = useSearch((snapshot) => snapshot.matches({ open: 'failed' })); + const text = useSearch((snapshot) => { + const { version, image, trials, result, error } = snapshot.context; + if (snapshot.matches({ open: 'failed' })) return error; + if (snapshot.matches({ open: 'empty' })) return version ? 'Ready.' : 'Loading the encoder.'; + if (snapshot.matches({ open: { searching: 'reading' } })) return `Reading ${image?.name}.`; + if (snapshot.matches({ open: 'result' })) return `Done in ${result?.seconds.toFixed(1)} s.`; + const trial = trials.at(-1); + if (!trial) return 'Encoding.'; + return `Encoding: trial ${trial.n} of at most ${trial.max}, quality ${trial.quality} scores ${trial.score.toFixed(1)}.`; + }); + + return ( +

+ {text} +

+ ); +} diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts new file mode 100644 index 0000000..0cfe1ae --- /dev/null +++ b/src/shared/api/index.ts @@ -0,0 +1,2 @@ +export { startEncoder, type Encoder } from './sqzer/client'; +export type { Codec, Decoded, EncodeOptions, Failure, Output, Reply, TrialProgress } from './sqzer/protocol'; diff --git a/src/shared/api/sqzer/client.ts b/src/shared/api/sqzer/client.ts new file mode 100644 index 0000000..5524ead --- /dev/null +++ b/src/shared/api/sqzer/client.ts @@ -0,0 +1,30 @@ +// The page's side of the worker, and the only code that starts, ends or talks to one. +import type { Reply, Request } from './protocol'; + +export type Encoder = { + send(request: Request, transfer?: Transferable[]): void; + /** The package is synchronous: ending its worker is the only way to stop a search. */ + terminate(): void; +}; + +export function startEncoder({ onMessage }: { onMessage: (reply: Reply) => void }): Encoder { + const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }); + + worker.onmessage = ({ data }: MessageEvent) => onMessage(data); + worker.onerror = (event) => { + event.preventDefault(); + // a script that did not load is a plain `Event`, with no message + const message = event.message || 'this browser has no module workers'; + onMessage({ type: 'error', stage: 'start', kind: 'Other', message }); + }; + + return { + send: (request, transfer = []) => worker.postMessage(request, transfer), + terminate() { + // a worker that was ended may still have messages on their way + worker.onmessage = null; + worker.onerror = null; + worker.terminate(); + }, + }; +} diff --git a/src/shared/api/sqzer/protocol.ts b/src/shared/api/sqzer/protocol.ts new file mode 100644 index 0000000..34bb76b --- /dev/null +++ b/src/shared/api/sqzer/protocol.ts @@ -0,0 +1,52 @@ +// The messages between the page and the worker. The worker holds one image +// at a time, and every message names it by `id`: a reply for an image that +// was replaced since is dropped by whoever reads it. +import type { Codec, Options, Output as Encoded, SqzerError, TrialProgress } from 'sqzer'; + +export type { Codec, TrialProgress }; + +/** Bytes that crossed from the worker: transferred, so in a buffer of their own. */ +export type Bytes = Uint8Array; + +/** What `encode` returns, as it reaches the page. */ +export type Output = Omit & { bytes: Bytes }; + +/** The options of the package that can cross to the worker. `onTrial` is the worker's own. */ +export type EncodeOptions = Omit; + +/** What the worker says about the image it holds. */ +export type Decoded = { + width: number; + height: number; + /** Left out for an image the page drew itself. */ + format: string | undefined; + alpha: boolean; + animated: boolean; +}; + +export type Request = + /** A file, as it was picked. */ + | { type: 'decode'; id: number; bytes: ArrayBuffer } + /** RGBA the page drew itself. */ + | { type: 'pixels'; id: number; rgba: ArrayBuffer; width: number; height: number } + | { type: 'encode'; id: number; options: EncodeOptions } + /** The image the worker holds, as a file every browser can show. */ + | { type: 'preview'; id: number }; + +export type Failure = { + type: 'error'; + /** `start` and `load` come before any request and name no image, the rest name the request that failed. */ + stage: 'start' | 'load' | Request['type']; + id?: number; + kind: SqzerError['kind']; + message: string; + availableIn?: string[]; +}; + +export type Reply = + | { type: 'ready'; version: string; codecs: Codec[] } + | { type: 'decoded'; id: number; image: Decoded } + | { type: 'trial'; id: number; trial: TrialProgress } + | { type: 'encoded'; id: number; result: Output } + | { type: 'previewed'; id: number; bytes: Bytes } + | Failure; diff --git a/src/shared/api/sqzer/worker.ts b/src/shared/api/sqzer/worker.ts new file mode 100644 index 0000000..bbf3e87 --- /dev/null +++ b/src/shared/api/sqzer/worker.ts @@ -0,0 +1,76 @@ +// The worker: every call into the `sqzer` package happens here, so a search +// that takes seconds never blocks the page. One image is held at a time. +// The messages are in `protocol.ts`. +import init, { codecs, decodeAny, fromPixels, type SqzerError, type SqzerImage } from 'sqzer'; +// The version comes from the package itself: the root `package.json` and the +// lockfile are the only places it is written. +import { version } from 'sqzer/package.json'; +import type { Bytes, Failure, Reply, Request } from './protocol'; + +function post(reply: Reply, transfer: Transferable[] = []) { + postMessage(reply, { transfer }); +} + +// The package copies its output out of the module's memory, into a buffer that can be transferred. +const own = (bytes: Uint8Array) => bytes as Bytes; + +async function load() { + await init(); + post({ type: 'ready', version, codecs: codecs() }); +} + +const loading = load(); +let image: SqzerImage | null = null; +let held = 0; + +function hold(id: number, decoded: SqzerImage) { + image?.free(); + image = decoded; + held = id; + const { width, height, format, alpha, animated } = decoded; + post({ type: 'decoded', id, image: { width, height, format, alpha, animated } }); +} + +function failure(stage: Failure['stage'], thrown: unknown, id?: number): Failure { + // the package throws a `SqzerError`. anything else has no `kind` + const { kind = 'Other', message = String(thrown), availableIn } = (thrown ?? {}) as Partial; + return { type: 'error', stage, id, kind, message, availableIn }; +} + +async function handle(request: Request) { + await loading; + const { id } = request; + try { + if (request.type === 'decode') { + hold(id, await decodeAny(new Uint8Array(request.bytes))); + } else if (request.type === 'pixels') { + const { rgba, width, height } = request; + hold(id, fromPixels(new Uint8ClampedArray(rgba), width, height)); + } else if (!image || id !== held) { + // a request for an image that failed to decode, or was replaced since + } else if (request.type === 'encode') { + const result = image.encode({ + ...request.options, + onTrial: (trial) => post({ type: 'trial', id, trial }), + }); + const bytes = own(result.bytes); + post({ type: 'encoded', id, result: { ...result, bytes } }, [bytes.buffer]); + } else { + // lossless, so what the page shows as "before" is what the encoder saw + const bytes = own(image.encode({ format: 'webp', lossless: true }).bytes); + post({ type: 'previewed', id, bytes }, [bytes.buffer]); + } + } catch (thrown) { + post(failure(request.type, thrown, id)); + } +} + +// One message at a time, in order: `decodeAny` is asynchronous, and an +// encode must not start before the decode it follows has finished. +let queue = Promise.resolve(); +addEventListener('message', ({ data }: MessageEvent) => { + queue = queue.then(() => handle(data)); +}); + +// A failed load answers every message with a rejection nobody sees, so say it once. +loading.catch((thrown: unknown) => post(failure('load', thrown))); diff --git a/src/shared/lib/object-url.ts b/src/shared/lib/object-url.ts new file mode 100644 index 0000000..033ac73 --- /dev/null +++ b/src/shared/lib/object-url.ts @@ -0,0 +1,21 @@ +import { useEffect, useRef } from 'react'; + +/** + * A ref for an element whose `attribute` is to point at `blob`. The object + * URL is made and revoked together with the attribute, so no render ever + * holds one that was revoked. + */ +export function useObjectUrl(blob: Blob | null, attribute: 'src' | 'href') { + const ref = useRef(null); + useEffect(() => { + const element = ref.current; + if (!element || !blob) return; + const url = URL.createObjectURL(blob); + element.setAttribute(attribute, url); + return () => { + element.removeAttribute(attribute); + URL.revokeObjectURL(url); + }; + }, [blob, attribute]); + return ref; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..61548b8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + "paths": { "@/*": ["./src/*"] }, + "strict": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9b50b2e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,32 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// The page talks to its own origin and to nothing else. Written once, here (ADR-0002 D3). +const csp = + "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; " + + "connect-src 'self'; img-src 'self' blob:; style-src 'self'; base-uri 'none'; form-action 'none'"; +const charset = ''; + +export default defineConfig({ + plugins: [ + // React Compiler in Rust. the fallback: `react()` plus `babel({ presets: [reactCompilerPreset()] })` + react({ compiler: true }), + { + // the development server goes without the policy: Fast Refresh there is an inline script + name: 'csp', + apply: 'build', + transformIndexHtml(html) { + // a browser reads the charset in the first 1024 bytes, so the policy goes after it + if (!html.includes(charset)) throw new Error(`index.html has no ${charset} to put the policy after`); + return html.replace(charset, `${charset}\n`); + }, + }, + ], + resolve: { tsconfigPaths: true }, + // no `data:` URIs: Vite inlines small assets as base64 by default, and the policy refuses them + build: { assetsInlineLimit: 0 }, + // the worker is a module, and imports `sqzer` like any other module + worker: { format: 'es' }, + // pre-bundling in development would move `sqzer.js` away from its `sqzer_bg.wasm` + optimizeDeps: { exclude: ['sqzer'] }, +}); From 588c9a6ffdab41f370d6485af32f97ce6c3171f4 Mon Sep 17 00:00:00 2001 From: Vladyslav Vladinov Date: Sun, 4 Oct 2026 20:46:47 +0200 Subject: [PATCH 2/8] ci: the checks of ADR-0002 D6, strict, and the build in Pages ADR-0002 items 2 to 4. The `browser` job runs `tsc` over two strict projects, oxlint with its type-aware rules and the React Compiler rules of `eslint-plugin-react-hooks`, `oxfmt --check`, Steiger, Vitest in browser mode and the Playwright suite, the last two in Chromium and Firefox. `/selftest/` is `decode-any.test.ts`, on the page and in a worker, and `.github/check.mjs` is `tests/e2e/page.spec.ts`: one JPEG through the built page, a change of format during a search, the policy, and every request on the page's own origin. The search machine and the controls have tests of their own. `pages.yml` builds and uploads `dist/`. Dependabot watches the root, with the development tooling grouped. `.vscode/` has the settings for the same tools. Co-Authored-By: Claude Fable 5.1 --- .github/check.mjs | 91 - .github/dependabot.yml | 10 +- .github/workflows/check.yml | 24 +- .github/workflows/pages.yml | 12 +- .gitignore | 2 + .greptile/config.json | 67 +- .greptile/files.json | 39 +- .oxfmtrc.json | 8 + .oxlintrc.json | 61 + .vscode/extensions.json | 3 + .vscode/settings.json | 27 + docs/adr/0002-stack.md | 6 +- docs/adr/0004-worker-lifetime.md | 2 +- index.html | 25 +- package.json | 26 +- playwright.config.ts | 23 + pnpm-lock.yaml | 2613 +++++++++++++++++- src/app/main.tsx | 4 +- src/app/style.css | 61 +- src/pages/compress/lib/options-of.ts | 4 +- src/pages/compress/lib/summary.ts | 4 +- src/pages/compress/model/context.ts | 1 + src/pages/compress/model/encode-result.ts | 2 +- src/pages/compress/model/encoder.ts | 189 +- src/pages/compress/model/machine.test.ts | 206 ++ src/pages/compress/model/machine.ts | 13 +- src/pages/compress/model/picked.ts | 2 +- src/pages/compress/ui/comparison.tsx | 28 +- src/pages/compress/ui/compress-page.tsx | 49 +- src/pages/compress/ui/control-panel.test.tsx | 87 + src/pages/compress/ui/control-panel.tsx | 89 +- src/pages/compress/ui/drop-zone.tsx | 75 +- src/pages/compress/ui/result-panel.tsx | 7 +- src/pages/compress/ui/search-status.tsx | 2 +- src/shared/api/sqzer/client.ts | 37 +- src/shared/api/sqzer/decode-any.checks.ts | 168 ++ src/shared/api/sqzer/decode-any.test.ts | 36 + src/shared/api/sqzer/decode-any.worker.ts | 8 + src/shared/api/sqzer/protocol.ts | 4 +- src/shared/api/sqzer/worker.ts | 30 +- tests/e2e/page.spec.ts | 123 + tests/fixtures/pattern-rgb.heic | Bin 0 -> 769 bytes tests/fixtures/pattern-rgb.jpg | Bin 0 -> 673 bytes tests/fixtures/pattern-rgb.svg | 1540 +++++++++++ tsconfig.app.json | 10 + tsconfig.base.json | 25 + tsconfig.json | 19 +- tsconfig.node.json | 9 + vite.config.ts | 7 +- vitest.config.ts | 24 + 50 files changed, 5314 insertions(+), 588 deletions(-) delete mode 100644 .github/check.mjs create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 playwright.config.ts create mode 100644 src/pages/compress/model/machine.test.ts create mode 100644 src/pages/compress/ui/control-panel.test.tsx create mode 100644 src/shared/api/sqzer/decode-any.checks.ts create mode 100644 src/shared/api/sqzer/decode-any.test.ts create mode 100644 src/shared/api/sqzer/decode-any.worker.ts create mode 100644 tests/e2e/page.spec.ts create mode 100644 tests/fixtures/pattern-rgb.heic create mode 100644 tests/fixtures/pattern-rgb.jpg create mode 100644 tests/fixtures/pattern-rgb.svg create mode 100644 tsconfig.app.json create mode 100644 tsconfig.base.json create mode 100644 tsconfig.node.json create mode 100644 vitest.config.ts diff --git a/.github/check.mjs b/.github/check.mjs deleted file mode 100644 index 4912472..0000000 --- a/.github/check.mjs +++ /dev/null @@ -1,91 +0,0 @@ -// The page in a headless browser, against the version of `sqzer` that -// `site/package.json` names. Run by `.github/workflows/check.yml` on every -// pull request, the Dependabot bumps included, with `site/` served at `URL`. -// -// npm install --no-save --no-package-lock playwright@1.63.0 -// npx playwright install --with-deps chromium -// python3 -m http.server -d site 8000 & -// node .github/check.mjs -import { chromium } from "playwright"; - -const url = process.env.URL ?? "http://localhost:8000/"; -const problems = []; - -// The server was started a moment ago and may not be listening yet. -for (let attempt = 1; ; attempt++) { - try { - const response = await fetch(url); - if (response.ok) break; - throw new Error(`HTTP ${response.status}`); - } catch (e) { - if (attempt === 20) throw new Error(`${url} is not serving: ${e.message}`); - await new Promise((done) => setTimeout(done, 500)); - } -} - -const browser = await chromium.launch(); -const page = await browser.newPage(); -page.on("pageerror", (e) => problems.push(`page error: ${e.message}`)); -page.on("console", (m) => { - if (m.type() === "error") problems.push(`console: ${m.text()}`); -}); -const status = () => page.textContent("#status"); - -try { - // 1. the self-test: `decodeAny` on the page and in a worker - await page.goto(`${url}selftest/`); - await page.waitForFunction(() => document.getElementById("out").textContent.endsWith("done\n"), null, { timeout: 120_000 }); - const out = await page.textContent("#out"); - console.log(out); - problems.push(...out.split("\n").filter((line) => line.startsWith("FAIL"))); - - // 2. the page: a JPEG in, an image out, with the defaults - const settled = (done) => - page.waitForFunction((done) => { - const s = document.getElementById("status"); - return done.test(s.textContent) || s.classList.contains("failed"); - }, done, { timeout: 120_000 }); - await page.goto(url); - await settled(/^Ready\./); - if (await status() === "Ready.") { - await page.setInputFiles("#file", new URL("../site/selftest/fixtures/pattern-rgb.jpg", import.meta.url).pathname); - await settled(/^Done/); - } - const summary = await page.textContent("#summary"); - console.log(`${await status()}\n${summary}`); - if (!/^Done/.test(await status())) { - problems.push(`the page: ${await status()}`); - } else { - // the download is a picture of the fixture's size, not just a link. The - // page's CSP lets an load a blob and nothing else read one, so the - // byte count comes from the result block, as the command line prints it - const made = await page.evaluate(async () => { - const a = document.getElementById("download"); - if (a.hidden || !a.href) return "no download"; - const img = new Image(); - img.src = a.href; - try { - await img.decode(); - return { name: a.download, width: img.naturalWidth, height: img.naturalHeight }; - } catch (e) { - return `${a.download} is no image this browser decodes: ${e.message}`; - } - }); - const bytes = summary.match(/-> (\d+(?:\.\d+)? [KM]?B) /)?.[1]; - console.log({ ...(typeof made === "string" ? { made } : made), bytes }); - if (typeof made === "string") problems.push(`the page: ${made}`); - else if (made.width !== 48 || made.height !== 32) problems.push(`the page: the output is ${made.width}x${made.height}, the fixture is 48x32`); - if (!bytes || bytes === "0 B") problems.push(`the page: the output has ${bytes ?? "no"} bytes`); - } -} catch (e) { - // what the browser said before the wait ran out is the diagnosis - problems.push(`the check did not finish: ${e.message.split("\n")[0]}`); -} finally { - await browser.close(); -} - -if (problems.length) { - console.error(`\n${problems.join("\n")}`); - process.exit(1); -} -console.log("\nok"); diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d2eaaef..468be1c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,12 @@ version: 2 updates: - # The version of `sqzer` the page imports, which `site/worker.js` reads - # from `site/package.json`. Merging the pull request is the whole bump. + # `sqzer` comes alone: a release of the package is one pull request with + # nothing else in it, and merging it is the whole bump. The build and test + # tooling comes grouped. - package-ecosystem: npm - directory: /site + directory: / schedule: interval: daily + groups: + tooling: + dependency-type: development diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 01a87fd..44ce6bd 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,7 +1,7 @@ name: Check -# The page in headless Chromium against the version of `sqzer` that -# `site/package.json` names, so a Dependabot bump is tested before it merges. +# The checks of ADR-0002 D6, against the version of `sqzer` that `package.json` +# names, so a Dependabot bump is tested before it merges. on: pull_request: push: @@ -17,7 +17,19 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - run: npm install --no-save --no-package-lock playwright@1.63.0 - - run: npx playwright install --with-deps chromium - - run: python3 -m http.server -d site 8000 & - - run: node .github/check.mjs + # the version of pnpm is the `packageManager` of `package.json` + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm lint + - run: pnpm format:check + - run: pnpm fsd + - run: pnpm exec playwright install --with-deps chromium firefox + # units, components and `decodeAny`, in Chromium and Firefox + - run: pnpm test + # the built page, served by `vite preview`, in Chromium and Firefox + - run: pnpm test:e2e diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 02ad25e..1ba8ed0 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,7 +1,7 @@ name: Pages # Every merge to `main` is a deploy: the page never waits for a release of -# the crates. `site/` is published as it is, there is no build step. +# the crates. The page is built by Vite and `dist/` is published. on: push: branches: [main] @@ -26,8 +26,16 @@ jobs: url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@v7 + # the version of pnpm is the `packageManager` of `package.json` + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build - uses: actions/upload-pages-artifact@v5 with: - path: site + path: dist - id: deploy uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index bf56190..123892c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store node_modules/ dist/ +test-results/ +playwright-report/ diff --git a/.greptile/config.json b/.greptile/config.json index b5edace..a4de953 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -1,78 +1,46 @@ { "strictness": 2, - "commentTypes": [ - "logic", - "syntax" - ], - "autoReview": [ - "open", - "push" - ], + "commentTypes": ["logic", "syntax"], + "autoReview": ["open", "push"], "statusCheck": true, - "excludeAuthors": [ - "dependabot[bot]" - ], - "ignorePatterns": "site/selftest/fixtures/*\nLICENSE-*\nCODE_OF_CONDUCT.md", + "excludeAuthors": ["dependabot[bot]"], + "ignorePatterns": "tests/fixtures/*\nLICENSE-*\nCODE_OF_CONDUCT.md", "instructions": "sqzer.dev is the page that runs the `sqzer` npm package in the browser. It moves from plain HTML to React 19, TypeScript and Vite under ADR-0002; until that port lands, `site/` is the page. CLAUDE.md is the rule book and docs/adr holds the decisions; where they disagree during the port, the accepted ADR wins. Review against them. The page depends on the published package by exact version and sends nothing anywhere. The page never writes a decoder, encoder, resampler or metric; what the package lacks is a change to sqzer-dev/sqzer, not to this repository.", "rules": [ { "id": "no-third-party", "rule": "The page talks to its own origin and nothing else: no analytics, no error reporting, no font, script, stylesheet, image or wasm from another host, no `data:` URI. The `Content-Security-Policy` is written once in `vite.config.ts` and injected into the built `index.html`; it is not widened for convenience. Flag any new origin, any inline script in `index.html`, any `style` attribute or `