diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 0000000..6fa991d
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "typescript", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/README.md b/README.md
index b91837a..015a857 100644
--- a/README.md
+++ b/README.md
@@ -1,78 +1,5 @@
-# Product Filtering Condition Editor UI
-A Coding Exercise for UI Developers
-
-# Introduction
-
-Many capabilities of Salsify are built around filtered sets of products. Products at Salsify consist of properties and their values. Properties have a datatype.
-
-In order to create filtered sets of products in Salsify we created a condition editor. This editor is used to build a filter that Salsify applies to the full set of products. The resulting set of products, presented as a list, is updated as filters are added or changed.
-
-In order to create a filter users must choose a property, an operator, and one or more values. Due to the differences in property datatypes, not all operators apply to all properties.
-
-To complete this exercise please build a user interface to create a filter and update a list of products to reflect the results. Use the exercise to demonstrate not only a solution to the problem but your approach to software design and testing.
-
-Provide us with an archive containing the results of your work and a README file with a guided tour of your work, notes on your development process, how long you spent on the exercise, what assumptions you made, etc. If you wish, this may also be presented as a live site. In that case simply provide a link to the site and the README file mentioned above.
-
-# Specification
-
-This repository contains a mock `datastore` which includes sample products, property definitions including data types, and the complete set of filter operator. Using this datastore please create a web user interface with the following behavior:
-
-* A user can create a single filter
-* Filters have the form `[property] [operator] [property value]`
-* Creating or updating a filter causes the the list of products to update
-* A user can clear the filter to see all products
-
-Included are [wireframes](http://salsify.github.io/condition-editor-coding-exercise/docs/wireframe.pdf) to illustrate a potential implementation. Feel free to approach this solution in the manner you see fit, but keep in mind we will evaluate your submission more on software design than user experience.
-
-# Tips and Recommendations
-- No other Operators or data types will be introduced; they are static.
-- Properties and Products vary from customer to customer, you cannot depend on having the same properties or products available each time this application loads
-
-## Properties Types/Operators
-
-Operators define the relationship between properties and property values. Certain operators are only valid for certain property types. The behavior of each operator and the valid operators for each property type are defined as follows:
-
-| Operator | Description |
------------|--------------
-| Equals | Value exactly matches |
-| Is greater than | Value is greater than |
-| Is less than | Value is less than |
-| Has any value | Value is present |
-| Has no value | Value is absent |
-| Is any of | Value exactly matches one of several values |
-| Contains | Value contains the specified text |
-
-
-| Property Type | Valid Operators |
----------------- | ----------------
-| string | Equals |
-| | Has any value |
-| | Has no value |
-| | Is any of |
-| | Contains |
-| number | Equals |
-| | Is greater than |
-| | Is less than |
-| | Has any value |
-| | Has no value |
-| | Is any of |
-| enumerated | equals |
-| | Has any value |
-| | Has no value |
-| | Is any of |
-
-### Examples
-
-Here are some example property & input combinations and a description of their expected output. This table is meant to further clarify the expected behavior of the aforementioned operators.
-
-| Operator | Example Property | Example Value | Expected Output |
-| -------- | ---------------- | ------------------- | --------------- |
-| Equals | `Name` | `Headphones` | Products where `Name` is exactly `Headphones` |
-| Is greater than | `Price` | `20` | Products where the `Price` > `20` |
-| Is less than | `Price` | `20` | Products where `Price` < `20` |
-| Has any value | `Description` | --- | Products where `Description` is defined/is NOT null |
-| Has no value | `Description` | --- | Products where the `Description` is not defined/IS null |
-| Is any of | `Name` | `Headphones, Keys` | Products where the Name is either exactly `Headphones` OR exactly `Keys` |
-| Contains | `Name` | `phone` | Products where the Name string CONTAINS `phone` (e.g. `Headphones`, `Telephone`, `Cell Phone`, `Phone`) |
+# Product Filtering Condition Editor
+A React + TypeScript + Vite implementation of the [condition editor coding exercise](reference/EXERCISE_README.md), backed by a mocked GraphQL API (MSW).
+See **[SOLUTION.md](SOLUTION.md)** for setup/run/test commands, a guided tour of the codebase, architecture decisions, and testing notes.
diff --git a/SOLUTION.md b/SOLUTION.md
new file mode 100644
index 0000000..73ea79a
--- /dev/null
+++ b/SOLUTION.md
@@ -0,0 +1,71 @@
+# Solution: Product Filtering Condition Editor
+
+A guided tour of this solution to the [condition editor coding exercise](reference/EXERCISE_README.md).
+
+## Setup / run / test
+
+```sh
+npm install
+npm run dev # http://localhost:5173 — MSW mocks the GraphQL API in the browser
+npm run test # unit + component tests (Vitest + React Testing Library, MSW-mocked)
+npm run typecheck # tsc -b, no emit
+npm run lint # oxlint
+npm run build # tsc -b && vite build — production bundle in dist/
+```
+
+All four of `test`, `typecheck`, `lint`, and `build` pass as of this commit.
+
+## Guided tour
+
+- **`reference/`** — the original exercise files (`EXERCISE_README.md`, `datastore.js`, `wireframe.pdf`), untouched, kept for context. Nothing in `src/` imports from here; the dataset is reimplemented behind a GraphQL API instead (see below).
+- **`src/api/`** — the GraphQL client layer.
+ - `types.ts` — TypeScript types for `Property`, `Product`, `Operator`, `ReferenceData`, `ConditionInput`, etc., describing what the mocked API accepts and returns.
+ - `queries.ts` — two queries: `ReferenceData` (properties + operators, static) and `Products($condition: ConditionInput)` (the filtered — or, with no condition, full — product list).
+ - `client.ts` — a `graphql-request` `GraphQLClient` pointed at a (placeholder) GraphQL endpoint; `request(document, variables)` is what lets `Products` be called parameterized.
+ - `useReferenceData.ts` — fetches properties + operators once on mount, exposing `{status: 'loading'|'error'|'success', ...}`.
+ - `useProducts.ts` — fetches products for the current `Condition`, refetching whenever the *effective* condition changes (see below).
+- **`src/domain/`** — pure, framework-free business logic, and the most heavily tested part of the codebase. No longer wired into the render path (see "Architecture" below) — it's what the mock server calls instead.
+ - `operators.ts` — the property-type → valid-operators validity matrix from the README.
+ - `filter.ts` — `evaluateCondition` (does one product match one condition?), `filterProducts`, and `isConditionComplete`.
+- **`src/mocks/`** — the reimplemented dataset and its MSW GraphQL handlers.
+ - `data.ts` — the same products/properties/operators as `datastore.js`, camelCased for GraphQL, including products 3-5 deliberately omitting their `wireless` value.
+ - `handlers.ts` — two MSW handlers: `graphql.query('ReferenceData', ...)` resolves with properties + operators; `graphql.query('Products', ...)` reads the `condition` variable and resolves with `filterProducts(allProducts, condition, properties)` — this is where filtering actually happens now.
+ - `browser.ts` / `server.ts` — the MSW worker (used in dev, wired up in `main.tsx`) and MSW server (used in tests, wired up in `setupTests.ts`), sharing the same `handlers`.
+- **`src/components/`** — `ConditionEditor` (property/operator/value selection), `ValueInput` (renders the right input for the selected property type + operator), `ProductList` (the live-updating table, now just rendering whatever `useProducts` holds).
+- **`src/App.tsx`** — fetches reference data once, holds the current `Condition` in state, and passes it straight to `useProducts`. No client-side filtering call in the render path — `App` renders whatever `useProducts` returns.
+
+## Architecture / stack choices
+
+- **Vite + React + TypeScript**: as specified. `npm create vite@latest -- --template react-ts` scaffold, since the exercise didn't ask for a hand-rolled build.
+- **`graphql-request` over `urql`**: two small queries, no caching strategy beyond "refetch `products` when its variable changes", and no mutations — `urql`'s normalized cache and exchange pipeline would be pure overhead here. `graphql-request` is a thin `fetch` wrapper, so the "client-side machinery" the exercise asks for is a couple of small hooks (`useReferenceData`, `useProducts`) rather than a library's internals, which felt like the more honest demonstration of the plumbing for a dataset this size.
+- **MSW (`msw/browser` in dev, `msw/node` in tests) sharing one `handlers.ts`**: the same mocked GraphQL API backs both the running app and the test suite, so a component test failure means the *real* UI code has a bug, not that a hand-rolled test double drifted from what the app actually calls.
+- **No real GraphQL schema/executor**: `handlers.ts` matches requests by operation name (`graphql.query('ReferenceData', ...)`, `graphql.query('Products', ...)`) and returns a plain JS object shaped like the query — there's no `graphql-js` schema validating it against SDL, so `ConditionInput` is a TypeScript type on the client/handler side rather than a validated GraphQL input type. For a mocked, read-only API this is the standard MSW pattern and kept the surface area proportional to the exercise; a real backend would obviously need an actual executable schema (SDL for `ConditionInput`, a real resolver for `products(condition:)`, etc.).
+- **Filtering happens on the server (the mocked GraphQL API), not the client.** Properties and operators are static reference data for the session and are still fetched once via `ReferenceData`. Products are fetched via a separate `products(condition: ConditionInput)` query: `ConditionEditor` edits a `Condition` value in React state, `useProducts` sends it as the `condition` variable, and MSW's `Products` handler (`src/mocks/handlers.ts`) applies `filterProducts`/`evaluateCondition` — the same domain logic as before — to decide what comes back. `App` and `ProductList` just render whatever that query currently holds; there's no `filterProducts` call left in the render path. `useProducts` only sends `condition` once it's actually complete (`isConditionComplete`) — a property-only or value-less condition would filter identically to "no condition" server-side, so the client asks for the unfiltered list instead of round-tripping a condition the server would treat the same way — and refetches whenever that *effective* condition changes (derived to a stable key so building an incomplete condition doesn't cause redundant requests). This means changing the filter is now a real request/response cycle against the mocked network layer, which is also how it's tested (see "Testing" below) — a component test that broke the `Products` handler, rather than a `filterProducts` unit test, is what would catch a regression in "does changing the condition actually change what's on screen".
+- **Why split into two queries instead of one parameterized `Catalog` query**: properties/operators and products have different lifetimes — the former never change once loaded, the latter change on every condition edit. Splitting them means editing a condition only ever triggers the cheaper, product-only round trip, and reference data doesn't get needlessly re-sent (or re-diffed) on every keystroke.
+- **Data-driven UI**: the property dropdown, operator options (filtered per type via `getValidOperatorIds`), and the `ProductList` table's columns are all built from whatever `properties`/`operators`/`products` come back from the query — nothing hardcodes "5 properties" or specific property names/ids. Swap the mock dataset for a different customer's data and the UI adapts without code changes.
+
+## Testing
+
+- **`domain/filter.test.ts`** — `evaluateCondition` and `filterProducts` against:
+ - Each README worked example (`equals`, `greater_than`/`less_than`, `any`/`none`, `in`, `contains`), run against the real mock dataset.
+ - The `contains` example specifically, including product names the mock dataset doesn't contain (`Telephone`, `Phone`) via a small synthetic fixture, so the exact README wording is covered even where the shipped dataset only has two of the four example matches.
+ - `any`/`none` against products 3-5, which is the dataset's designed test case for a missing property value.
+ - A synthetic-fixture pass over the *entire* property-type × operator matrix (string/number/enumerated × all 7 operators, restricted to what's valid for each), independent of the specific mock dataset.
+
+ These stayed as direct, pure unit tests of the domain module rather than moving behind the network layer: they're exercising the matching semantics themselves — the same code the `Products` MSW handler calls — and pinning that down at the unit level (property-type/operator validity matrix, worked examples) is more precise and faster than driving every combination through a rendered component and a mocked request.
+- **`domain/operators.test.ts`** — asserts the validity matrix itself: exactly which operators are valid for each of the three property types, including the negative cases (`contains` invalid for number/enumerated, `greater_than`/`less_than` invalid for string/enumerated).
+- **`App.test.tsx`** — component/integration tests rendering the real `App`, going through the real `useReferenceData`/`useProducts` hooks and the real MSW `ReferenceData`/`Products` handlers — nothing about filtering is stubbed at the React level. Because filtering is now a network round trip, assertions that depend on a filtered result `await` it (`waitFor`) rather than reading the DOM synchronously right after an interaction; this is deliberate, not incidental — it's what proves the UI is actually waiting on the `Products` response instead of computing the list itself. Covers: initial full list, live filtering as property/operator/value change (numeric, string `contains`, enumerated `any`/`none`, enumerated `in` via checkboxes), operator options narrowing per property type, clear-filter restoring the full list, and a request/response test that spies on the `Products` handler's `variables` directly to assert the client omits `condition` while a condition is incomplete and sends the exact `{propertyId, operatorId, value}` shape once it's complete.
+
+Run `npm run test` for the full suite.
+
+## Assumptions and deviations worth a second look
+
+- **`contains` is case-insensitive**; `equals` and `in` are exact/case-sensitive matches. The README doesn't state case sensitivity explicitly; case-insensitive substring search felt like the more useful default for a "contains" search box, while `equals`/`in` staying exact preserves "exactly matches" as written.
+- **`in`'s value input is a comma-separated text field** for string/number properties (matching the README's `Headphones, Keys` example verbatim) and a checkbox group for enumerated properties (so users pick from the actual allowed values rather than typing them).
+- **The GraphQL endpoint (`http://localhost/graphql`) is a placeholder**, not a real reachable host — MSW intercepts by operation name regardless of the request URL, and Node's global `fetch` (which is what runs under Vitest even with `environment: "jsdom"`) rejects a bare relative path like `/graphql` with "Invalid URL". Pointing this at a real backend would just mean making the endpoint configurable (e.g. via an env var) and dropping the MSW `worker.start()` call in `main.tsx`.
+- **No routing, no persistence** of the condition across reloads — out of scope per the spec ("a single filter").
+- **Numbers compare numerically even if a raw value arrives as a string** (`toNumber` coerces); this only matters if a future dataset stores numeric properties as strings, but it's cheap defensiveness given the mocked layer already treats `value` as `string | number`.
+
+## Time spent
+
+Roughly 2-3 hours: reading the spec and datastore closely, scaffolding, building the GraphQL/MSW plumbing, the domain logic and its tests, the UI, and the component tests.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..3e40cd3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Condition Editor
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..aa20ce2
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3112 @@
+{
+ "name": "app",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "app",
+ "version": "0.0.0",
+ "dependencies": {
+ "graphql": "^16.14.2",
+ "graphql-request": "^7.4.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.6",
+ "@types/node": "^24.13.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.1.0",
+ "jsdom": "^30.0.1",
+ "msw": "^2.15.0",
+ "oxlint": "^1.79.0",
+ "typescript": "~6.0.2",
+ "vite": "^8.2.2",
+ "vitest": "^4.1.11"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
+ "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.3.0",
+ "@csstools/css-color-parser": "^4.1.10",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
+ "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.10.tgz",
+ "integrity": "sha512-xBja6gaAaH2R2c7eNyl0TY4dhnnZ2uhj+KXpLdEQ6M/wuk9bYFZM8wY0ykw3VO4TgEJ56KGlerXS/9KBKVR/Cg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@graphql-typed-document-node/core": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
+ "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@inquirer/ansi": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
+ "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.0.tgz",
+ "integrity": "sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^12.0.1",
+ "@inquirer/type": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz",
+ "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.7",
+ "@inquirer/figures": "^2.0.8",
+ "@inquirer/type": "^4.1.0",
+ "cli-width": "^4.1.0",
+ "fast-wrap-ansi": "^0.2.0",
+ "mute-stream": "^3.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz",
+ "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.0.tgz",
+ "integrity": "sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@mswjs/interceptors": {
+ "version": "0.41.9",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
+ "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/logger": "^0.3.0",
+ "@open-draft/until": "^2.0.0",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "strict-event-emitter": "^0.5.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/deferred-promise": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz",
+ "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/logger": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+ "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.0"
+ }
+ },
+ "node_modules/@open-draft/until": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz",
+ "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz",
+ "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz",
+ "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz",
+ "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz",
+ "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz",
+ "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz",
+ "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz",
+ "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz",
+ "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz",
+ "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz",
+ "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz",
+ "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz",
+ "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz",
+ "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz",
+ "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz",
+ "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz",
+ "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz",
+ "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz",
+ "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.6",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
+ "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/set-cookie-parser": {
+ "version": "2.4.10",
+ "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz",
+ "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/statuses": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
+ "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/graphql": {
+ "version": "16.14.2",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz",
+ "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
+ "node_modules/graphql-request": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.4.0.tgz",
+ "integrity": "sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.2.0"
+ },
+ "peerDependencies": {
+ "graphql": "14 - 16"
+ }
+ },
+ "node_modules/headers-polyfill": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz",
+ "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/set-cookie-parser": "^2.4.10",
+ "set-cookie-parser": "^3.0.1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/msw": {
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz",
+ "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/confirm": "^6.0.11",
+ "@mswjs/interceptors": "^0.41.3",
+ "@open-draft/deferred-promise": "^3.0.0",
+ "@types/statuses": "^2.0.6",
+ "cookie": "^1.1.1",
+ "graphql": "^16.13.2",
+ "headers-polyfill": "^5.0.1",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "path-to-regexp": "^6.3.0",
+ "picocolors": "^1.1.1",
+ "rettime": "^0.11.11",
+ "statuses": "^2.0.2",
+ "strict-event-emitter": "^0.5.1",
+ "tough-cookie": "^6.0.1",
+ "type-fest": "^5.5.0",
+ "until-async": "^3.0.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "msw": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mswjs"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.8.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mute-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
+ "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/outvariant": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/oxlint": {
+ "version": "1.80.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz",
+ "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.80.0",
+ "@oxlint/binding-android-arm64": "1.80.0",
+ "@oxlint/binding-darwin-arm64": "1.80.0",
+ "@oxlint/binding-darwin-x64": "1.80.0",
+ "@oxlint/binding-freebsd-x64": "1.80.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.80.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.80.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.80.0",
+ "@oxlint/binding-linux-arm64-musl": "1.80.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.80.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.80.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.80.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.80.0",
+ "@oxlint/binding-linux-x64-gnu": "1.80.0",
+ "@oxlint/binding-linux-x64-musl": "1.80.0",
+ "@oxlint/binding-openharmony-arm64": "1.80.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.80.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.80.0",
+ "@oxlint/binding-win32-x64-msvc": "1.80.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=7.0.2001",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rettime": {
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz",
+ "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.147.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
+ "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strict-event-emitter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz",
+ "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.11"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz",
+ "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
+ "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.10.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz",
+ "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/until-async": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+ "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/kettanaito"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+ "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2876db6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "condition-editor",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "typecheck": "tsc -b",
+ "lint": "oxlint",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "graphql": "^16.14.2",
+ "graphql-request": "^7.4.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.6",
+ "@types/node": "^24.13.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.1.0",
+ "jsdom": "^30.0.1",
+ "msw": "^2.15.0",
+ "oxlint": "^1.79.0",
+ "typescript": "~6.0.2",
+ "vite": "^8.2.2",
+ "vitest": "^4.1.11"
+ },
+ "msw": {
+ "workerDirectory": [
+ "public"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js
new file mode 100644
index 0000000..0c970ef
--- /dev/null
+++ b/public/mockServiceWorker.js
@@ -0,0 +1,361 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ */
+
+const PACKAGE_VERSION = '2.15.0'
+const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
+const activeClientIds = new Set()
+
+addEventListener('install', function () {
+ self.skipWaiting()
+})
+
+addEventListener('activate', function (event) {
+ event.waitUntil(self.clients.claim())
+})
+
+addEventListener('message', async function (event) {
+ const clientId = Reflect.get(event.source || {}, 'id')
+
+ if (!clientId || !self.clients) {
+ return
+ }
+
+ const client = await self.clients.get(clientId)
+
+ if (!client) {
+ return
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ })
+ break
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: {
+ packageVersion: PACKAGE_VERSION,
+ checksum: INTEGRITY_CHECKSUM,
+ },
+ })
+ break
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId)
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: {
+ client: {
+ id: client.id,
+ frameType: client.frameType,
+ },
+ },
+ })
+ break
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId)
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId
+ })
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister()
+ }
+
+ break
+ }
+ }
+})
+
+addEventListener('fetch', function (event) {
+ const requestInterceptedAt = Date.now()
+
+ // Bypass navigation requests.
+ if (event.request.mode === 'navigate') {
+ return
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (
+ event.request.cache === 'only-if-cached' &&
+ event.request.mode !== 'same-origin'
+ ) {
+ return
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been terminated (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return
+ }
+
+ const requestId = crypto.randomUUID()
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
+})
+
+/**
+ * @param {FetchEvent} event
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ */
+async function handleRequest(event, requestId, requestInterceptedAt) {
+ const client = await resolveMainClient(event)
+ const requestCloneForEvents = event.request.clone()
+ const response = await getResponse(
+ event,
+ client,
+ requestId,
+ requestInterceptedAt,
+ )
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ const serializedRequest = await serializeRequest(requestCloneForEvents)
+
+ // Omit the body of server-sent event stream responses.
+ // Cloning such responses would prevent client-side stream cancelations
+ // from reaching the original stream (a teed stream only cancels its
+ // source once both of its branches cancel) and would buffer the
+ // entire stream into the unconsumed clone indefinitely.
+ const isEventStreamResponse = response.headers
+ .get('content-type')
+ ?.toLowerCase()
+ .startsWith('text/event-stream')
+
+ // Clone the response so both the client and the library could consume it.
+ const responseClone = isEventStreamResponse ? null : response.clone()
+
+ sendToClient(
+ client,
+ {
+ type: 'RESPONSE',
+ payload: {
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
+ request: {
+ id: requestId,
+ ...serializedRequest,
+ },
+ response: {
+ type: response.type,
+ status: response.status,
+ statusText: response.statusText,
+ headers: Object.fromEntries(response.headers.entries()),
+ body: responseClone ? responseClone.body : null,
+ },
+ },
+ },
+ responseClone && responseClone.body
+ ? [serializedRequest.body, responseClone.body]
+ : [],
+ )
+ }
+
+ return response
+}
+
+/**
+ * Resolve the main client for the given event.
+ * Client that issues a request doesn't necessarily equal the client
+ * that registered the worker. It's with the latter the worker should
+ * communicate with during the response resolving phase.
+ * @param {FetchEvent} event
+ * @returns {Promise}
+ */
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId)
+
+ if (activeClientIds.has(event.clientId)) {
+ return client
+ }
+
+ if (client?.frameType === 'top-level') {
+ return client
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible'
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id)
+ })
+}
+
+/**
+ * @param {FetchEvent} event
+ * @param {Client | undefined} client
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ * @returns {Promise}
+ */
+async function getResponse(event, client, requestId, requestInterceptedAt) {
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const requestClone = event.request.clone()
+
+ function passthrough() {
+ // Cast the request headers to a new Headers instance
+ // so the headers can be manipulated with.
+ const headers = new Headers(requestClone.headers)
+
+ // Remove the "accept" header value that marked this request as passthrough.
+ // This prevents request alteration and also keeps it compliant with the
+ // user-defined CORS policies.
+ const acceptHeader = headers.get('accept')
+ if (acceptHeader) {
+ const values = acceptHeader.split(',').map((value) => value.trim())
+ const filteredValues = values.filter(
+ (value) => value !== 'msw/passthrough',
+ )
+
+ if (filteredValues.length > 0) {
+ headers.set('accept', filteredValues.join(', '))
+ } else {
+ headers.delete('accept')
+ }
+ }
+
+ return fetch(requestClone, { headers })
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough()
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough()
+ }
+
+ // Notify the client that a request has been intercepted.
+ const serializedRequest = await serializeRequest(event.request)
+ const clientMessage = await sendToClient(
+ client,
+ {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ interceptedAt: requestInterceptedAt,
+ ...serializedRequest,
+ },
+ },
+ [serializedRequest.body],
+ )
+
+ switch (clientMessage.type) {
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data)
+ }
+
+ case 'PASSTHROUGH': {
+ return passthrough()
+ }
+ }
+
+ return passthrough()
+}
+
+/**
+ * @param {Client} client
+ * @param {any} message
+ * @param {Array} transferrables
+ * @returns {Promise}
+ */
+function sendToClient(client, message, transferrables = []) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel()
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error)
+ }
+
+ resolve(event.data)
+ }
+
+ client.postMessage(message, [
+ channel.port2,
+ ...transferrables.filter(Boolean),
+ ])
+ })
+}
+
+/**
+ * @param {Response} response
+ * @returns {Response}
+ */
+function respondWithMock(response) {
+ // Setting response status code to 0 is a no-op.
+ // However, when responding with a "Response.error()", the produced Response
+ // instance will have status code set to 0. Since it's not possible to create
+ // a Response instance with status code 0, handle that use-case separately.
+ if (response.status === 0) {
+ return Response.error()
+ }
+
+ const mockedResponse = new Response(response.body, response)
+
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
+ value: true,
+ enumerable: true,
+ })
+
+ return mockedResponse
+}
+
+/**
+ * @param {Request} request
+ */
+async function serializeRequest(request) {
+ return {
+ url: request.url,
+ mode: request.mode,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: await request.arrayBuffer(),
+ keepalive: request.keepalive,
+ }
+}
diff --git a/reference/EXERCISE_README.md b/reference/EXERCISE_README.md
new file mode 100644
index 0000000..b91837a
--- /dev/null
+++ b/reference/EXERCISE_README.md
@@ -0,0 +1,78 @@
+# Product Filtering Condition Editor UI
+A Coding Exercise for UI Developers
+
+# Introduction
+
+Many capabilities of Salsify are built around filtered sets of products. Products at Salsify consist of properties and their values. Properties have a datatype.
+
+In order to create filtered sets of products in Salsify we created a condition editor. This editor is used to build a filter that Salsify applies to the full set of products. The resulting set of products, presented as a list, is updated as filters are added or changed.
+
+In order to create a filter users must choose a property, an operator, and one or more values. Due to the differences in property datatypes, not all operators apply to all properties.
+
+To complete this exercise please build a user interface to create a filter and update a list of products to reflect the results. Use the exercise to demonstrate not only a solution to the problem but your approach to software design and testing.
+
+Provide us with an archive containing the results of your work and a README file with a guided tour of your work, notes on your development process, how long you spent on the exercise, what assumptions you made, etc. If you wish, this may also be presented as a live site. In that case simply provide a link to the site and the README file mentioned above.
+
+# Specification
+
+This repository contains a mock `datastore` which includes sample products, property definitions including data types, and the complete set of filter operator. Using this datastore please create a web user interface with the following behavior:
+
+* A user can create a single filter
+* Filters have the form `[property] [operator] [property value]`
+* Creating or updating a filter causes the the list of products to update
+* A user can clear the filter to see all products
+
+Included are [wireframes](http://salsify.github.io/condition-editor-coding-exercise/docs/wireframe.pdf) to illustrate a potential implementation. Feel free to approach this solution in the manner you see fit, but keep in mind we will evaluate your submission more on software design than user experience.
+
+# Tips and Recommendations
+- No other Operators or data types will be introduced; they are static.
+- Properties and Products vary from customer to customer, you cannot depend on having the same properties or products available each time this application loads
+
+## Properties Types/Operators
+
+Operators define the relationship between properties and property values. Certain operators are only valid for certain property types. The behavior of each operator and the valid operators for each property type are defined as follows:
+
+| Operator | Description |
+-----------|--------------
+| Equals | Value exactly matches |
+| Is greater than | Value is greater than |
+| Is less than | Value is less than |
+| Has any value | Value is present |
+| Has no value | Value is absent |
+| Is any of | Value exactly matches one of several values |
+| Contains | Value contains the specified text |
+
+
+| Property Type | Valid Operators |
+---------------- | ----------------
+| string | Equals |
+| | Has any value |
+| | Has no value |
+| | Is any of |
+| | Contains |
+| number | Equals |
+| | Is greater than |
+| | Is less than |
+| | Has any value |
+| | Has no value |
+| | Is any of |
+| enumerated | equals |
+| | Has any value |
+| | Has no value |
+| | Is any of |
+
+### Examples
+
+Here are some example property & input combinations and a description of their expected output. This table is meant to further clarify the expected behavior of the aforementioned operators.
+
+| Operator | Example Property | Example Value | Expected Output |
+| -------- | ---------------- | ------------------- | --------------- |
+| Equals | `Name` | `Headphones` | Products where `Name` is exactly `Headphones` |
+| Is greater than | `Price` | `20` | Products where the `Price` > `20` |
+| Is less than | `Price` | `20` | Products where `Price` < `20` |
+| Has any value | `Description` | --- | Products where `Description` is defined/is NOT null |
+| Has no value | `Description` | --- | Products where the `Description` is not defined/IS null |
+| Is any of | `Name` | `Headphones, Keys` | Products where the Name is either exactly `Headphones` OR exactly `Keys` |
+| Contains | `Name` | `phone` | Products where the Name string CONTAINS `phone` (e.g. `Headphones`, `Telephone`, `Cell Phone`, `Phone`) |
+
+
diff --git a/datastore.js b/reference/datastore.js
similarity index 100%
rename from datastore.js
rename to reference/datastore.js
diff --git a/wireframe.pdf b/reference/wireframe.pdf
similarity index 100%
rename from wireframe.pdf
rename to reference/wireframe.pdf
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..f0ba1e6
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,102 @@
+.app {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 4rem;
+}
+
+.app h1 {
+ font-size: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.condition-editor {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 1rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1.25rem 1.5rem 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.condition-editor legend {
+ font-weight: 600;
+ padding: 0 0.25rem;
+}
+
+.condition-editor__field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ min-width: 10rem;
+ font-size: 0.85rem;
+}
+
+.condition-editor select,
+.condition-editor input[type="text"],
+.condition-editor input[type="number"] {
+ padding: 0.4rem 0.5rem;
+ font-size: 0.95rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--text-h);
+}
+
+.condition-editor button {
+ align-self: flex-end;
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ cursor: pointer;
+}
+
+.condition-editor button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.value-input--checkbox-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.value-input__checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.9rem;
+ font-weight: normal;
+}
+
+.product-list__count {
+ font-size: 0.85rem;
+ color: var(--text);
+ margin-bottom: 0.5rem;
+}
+
+.product-list__table-wrap {
+ overflow-x: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+
+th,
+td {
+ text-align: left;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+
+th {
+ font-weight: 600;
+ color: var(--text);
+}
diff --git a/src/App.test.tsx b/src/App.test.tsx
new file mode 100644
index 0000000..7c6c669
--- /dev/null
+++ b/src/App.test.tsx
@@ -0,0 +1,386 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { graphql, HttpResponse } from "msw";
+import { describe, expect, it } from "vitest";
+import App from "./App";
+import type { ConditionInput } from "./api/types";
+import { server } from "./mocks/server";
+
+/**
+ * These are component/integration tests: `App` renders for real, and MSW
+ * (wired up globally in `setupTests.ts`) intercepts its `ReferenceData` and
+ * `Products` GraphQL requests — nothing about the datastore or the
+ * filtering logic is stubbed at the React level. Filtering now happens in
+ * the `Products` MSW handler (see `src/mocks/handlers.ts`), not in `App`,
+ * so every assertion below that depends on a filtered result is exercising
+ * a real request/response cycle: changing the condition triggers a new
+ * `Products` request, and what lands on screen is whatever that mocked
+ * response contains — not a client-side computation over an already-fetched
+ * list. Because that's a real (if fast) async round trip, those assertions
+ * `await` via `waitFor` rather than reading the DOM synchronously right
+ * after an interaction.
+ */
+
+const FULL_LIST = [
+ "Cell Phone",
+ "Cup",
+ "Hammer",
+ "Headphones",
+ "Key",
+ "Keyboard",
+];
+
+function getProductNames(): string[] {
+ const table = screen.getByRole("table");
+ const rows = within(table).getAllByRole("row").slice(1); // skip header row
+ return rows.map((row) => within(row).getAllByRole("cell")[0].textContent!);
+}
+
+async function expectProductNames(expected: string[]) {
+ const wanted = [...expected].sort();
+ await waitFor(() => {
+ expect(getProductNames().sort()).toEqual(wanted);
+ });
+}
+
+async function waitForCatalog() {
+ await screen.findByRole("combobox", { name: "Property" });
+ // The initial (unfiltered) `products` request is separate from
+ // `ReferenceData` and resolves asynchronously too — wait for it so
+ // subsequent assertions aren't racing against an empty/loading table.
+ await expectProductNames(FULL_LIST);
+}
+
+describe("App", () => {
+ it("shows every product before any filter is applied", async () => {
+ render( );
+ await waitForCatalog();
+ });
+
+ it("does not filter until the condition is fully set (property only)", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "weight (oz)",
+ );
+
+ // Property selected, but no operator chosen by the user yet (the
+ // default operator is pre-filled with no value) — the condition isn't
+ // complete, so the client doesn't even send it; full list still.
+ await expectProductNames(FULL_LIST);
+ });
+
+ it("does not filter until a value-requiring operator's value is entered", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "weight (oz)",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is greater than",
+ );
+
+ // Operator needs a value ("Is greater than") but none has been typed
+ // yet — still the full list, not an empty (or wrongly filtered) one.
+ await expectProductNames(FULL_LIST);
+ });
+
+ it("filters live as a numeric condition is built (weight > 4)", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "weight (oz)",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is greater than",
+ );
+ await user.type(screen.getByRole("spinbutton", { name: "Value" }), "4");
+
+ await expectProductNames(["Hammer", "Headphones", "Keyboard"]);
+ });
+
+ it("filters using contains, matching the README worked example", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "Product Name",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Contains",
+ );
+ await user.type(screen.getByRole("textbox", { name: "Value" }), "phone");
+
+ await expectProductNames(["Cell Phone", "Headphones"]);
+ });
+
+ it("has no value input for 'Has any value' / 'Has no value', and filters correctly", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "wireless",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Has any value",
+ );
+
+ expect(screen.queryByLabelText("Value")).not.toBeInTheDocument();
+ // The "Value" field label itself must also be gone — not just its
+ // input — so there's no orphaned "Value" text left dangling above a
+ // hidden input.
+ expect(screen.queryByText("Value")).not.toBeInTheDocument();
+ await expectProductNames(["Cell Phone", "Headphones", "Keyboard"]);
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Has no value",
+ );
+ expect(screen.queryByText("Value")).not.toBeInTheDocument();
+ await expectProductNames(["Cup", "Hammer", "Key"]);
+ });
+
+ it("filters an enumerated property with 'Is any of' via a checkbox group", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "category",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is any of",
+ );
+
+ const group = screen.getByRole("group", { name: "Value" });
+ await user.click(within(group).getByLabelText("tools"));
+ await user.click(within(group).getByLabelText("kitchenware"));
+
+ await expectProductNames(["Cup", "Hammer", "Key"]);
+ });
+
+ it("only offers operators valid for the selected property's type", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "category",
+ );
+ const operatorSelect = screen.getByRole("combobox", { name: "Operator" });
+ const optionLabels = within(operatorSelect)
+ .getAllByRole("option")
+ .map((o) => o.textContent);
+
+ expect(optionLabels).toEqual([
+ "Equals",
+ "Has any value",
+ "Has no value",
+ "Is any of",
+ ]);
+ });
+
+ it("clearing the filter restores the full product list", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "Product Name",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Equals",
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Value" }),
+ "Headphones",
+ );
+ await expectProductNames(["Headphones"]);
+
+ await user.click(screen.getByRole("button", { name: "Clear filter" }));
+
+ await expectProductNames(FULL_LIST);
+ expect(
+ screen.queryByRole("combobox", { name: "Operator" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("allows typing a comma-separated list into a string 'Is any of' value, including mid-typing commas", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "Product Name",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is any of",
+ );
+
+ const valueInput = screen.getByRole("textbox", { name: "Value" });
+ await user.type(valueInput, "Headphones,");
+
+ // The trailing comma the user just typed must still be visible — it
+ // must not be silently stripped mid-typing, which is what made typing
+ // a second value look impossible.
+ expect(valueInput).toHaveValue("Headphones,");
+
+ await user.type(valueInput, " Key");
+ expect(valueInput).toHaveValue("Headphones, Key");
+
+ await expectProductNames(["Headphones", "Key"]);
+ });
+
+ it("allows typing a comma-separated list into a number 'Is any of' value, including mid-typing commas", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "weight (oz)",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is any of",
+ );
+
+ const valueInput = screen.getByRole("textbox", { name: "Value" });
+ await user.type(valueInput, "5,");
+
+ expect(valueInput).toHaveValue("5,");
+
+ await user.type(valueInput, " 1");
+ expect(valueInput).toHaveValue("5, 1");
+
+ await expectProductNames(["Headphones", "Keyboard", "Key"]);
+ });
+
+ it("resets the 'Is any of' free-text value when switching operators or properties", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "Product Name",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is any of",
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Value" }),
+ "Headphones, Key",
+ );
+
+ // Switching away and back to "Is any of" should not carry over the
+ // previous free text (the condition's value was reset to undefined).
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Contains",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is any of",
+ );
+
+ expect(screen.getByRole("textbox", { name: "Value" })).toHaveValue("");
+ });
+
+ it("restores the full list when a completed condition is changed back to an incomplete one", async () => {
+ const user = userEvent.setup();
+ render( );
+ await waitForCatalog();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "Product Name",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Equals",
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Value" }),
+ "Headphones",
+ );
+ await expectProductNames(["Headphones"]);
+
+ // Switching to an operator that still needs a value (but hasn't got
+ // one yet) should behave like a partial condition again, not keep
+ // the previous filtered result around.
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Contains",
+ );
+
+ await expectProductNames(FULL_LIST);
+ });
+
+ it("sends `condition` to the `Products` query only once it's complete, shaped as {propertyId, operatorId, value}", async () => {
+ const seenConditions: (ConditionInput | null | undefined)[] = [];
+ server.use(
+ graphql.query<
+ { products: unknown[] },
+ { condition?: ConditionInput | null }
+ >("Products", ({ variables }) => {
+ seenConditions.push(variables.condition);
+ return HttpResponse.json({ data: { products: [] } });
+ }),
+ );
+
+ const user = userEvent.setup();
+ render( );
+ await screen.findByRole("combobox", { name: "Property" });
+ // The initial mount fetch: no condition yet.
+ await waitFor(() => expect(seenConditions).toHaveLength(1));
+ expect(seenConditions[0]).toBeUndefined();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Property" }),
+ "weight (oz)",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Operator" }),
+ "Is greater than",
+ );
+ // Property + operator picked, no value yet — must not have triggered
+ // another `Products` request (an incomplete condition is equivalent
+ // to no condition, so there's nothing new to ask the server for).
+ expect(seenConditions).toHaveLength(1);
+
+ await user.type(screen.getByRole("spinbutton", { name: "Value" }), "4");
+
+ await waitFor(() => expect(seenConditions).toHaveLength(2));
+ expect(seenConditions[1]).toEqual({
+ propertyId: 2,
+ operatorId: "greater_than",
+ value: 4,
+ });
+ });
+});
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..90ff562
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useReferenceData } from "./api/useReferenceData";
+import { useProducts } from "./api/useProducts";
+import { ConditionEditor } from "./components/ConditionEditor";
+import { ProductList } from "./components/ProductList";
+import type { Operator, Property } from "./api/types";
+import type { Condition } from "./domain/filter";
+import "./App.css";
+
+// Stable empty-array fallbacks (rather than `[]` literals) so components
+// below don't see "new" props on every render while loading/erroring.
+const NO_PROPERTIES: Property[] = [];
+const NO_OPERATORS: Operator[] = [];
+
+function App() {
+ const referenceData = useReferenceData();
+ const [condition, setCondition] = useState(null);
+
+ // The server does the filtering (see `src/mocks/handlers.ts`) — this
+ // just renders whatever the `products` query currently holds for
+ // `condition`. `useProducts` keeps the previous list visible while a
+ // refetch is in flight, so there's no need to gate rendering on its
+ // status the way `referenceData`'s one-time load is gated below.
+ const productsState = useProducts(condition);
+
+ const properties =
+ referenceData.status === "success" ? referenceData.data.properties : NO_PROPERTIES;
+ const operators =
+ referenceData.status === "success" ? referenceData.data.operators : NO_OPERATORS;
+
+ if (referenceData.status === "loading") {
+ return (
+
+ Loading catalog…
+
+ );
+ }
+
+ if (referenceData.status === "error") {
+ return (
+
+
+ Failed to load catalog: {referenceData.error.message}
+
+
+ );
+ }
+
+ return (
+
+ Product Filter
+ setCondition(null)}
+ />
+ {productsState.status === "error" ? (
+
+ Failed to load products: {productsState.error.message}
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default App;
diff --git a/src/api/client.ts b/src/api/client.ts
new file mode 100644
index 0000000..2579910
--- /dev/null
+++ b/src/api/client.ts
@@ -0,0 +1,14 @@
+import { GraphQLClient } from "graphql-request";
+
+/**
+ * `fetch` requires an absolute URL both in the browser under some
+ * environments and, notably, under Node (which is what Vitest runs in,
+ * even with `environment: "jsdom"`) — a bare "/graphql" throws "Invalid
+ * URL" there. MSW's `graphql.query(...)` handlers match by GraphQL
+ * operation name regardless of the request URL, so this placeholder host
+ * never actually needs to be reachable; swap it for a real endpoint (e.g.
+ * via an env var) when pointing this app at a real GraphQL server.
+ */
+export const GRAPHQL_ENDPOINT = "http://localhost/graphql";
+
+export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT);
diff --git a/src/api/queries.ts b/src/api/queries.ts
new file mode 100644
index 0000000..ad9c70e
--- /dev/null
+++ b/src/api/queries.ts
@@ -0,0 +1,37 @@
+/**
+ * Properties and operators are static reference data for the session —
+ * fetched once, up front, independent of any condition the user builds.
+ */
+export const REFERENCE_DATA_QUERY = /* GraphQL */ `
+ query ReferenceData {
+ properties {
+ id
+ name
+ type
+ values
+ }
+ operators {
+ id
+ text
+ }
+ }
+`;
+
+/**
+ * Products are fetched (and filtered) per the current condition: the
+ * server applies `condition` and returns only matching products. Called
+ * with `condition` omitted/null, it returns the full, unfiltered list —
+ * which is exactly how the client calls it while the condition being built
+ * in the UI isn't complete yet (see `isConditionComplete`).
+ */
+export const PRODUCTS_QUERY = /* GraphQL */ `
+ query Products($condition: ConditionInput) {
+ products(condition: $condition) {
+ id
+ propertyValues {
+ propertyId
+ value
+ }
+ }
+ }
+`;
diff --git a/src/api/types.ts b/src/api/types.ts
new file mode 100644
index 0000000..db9ab20
--- /dev/null
+++ b/src/api/types.ts
@@ -0,0 +1,76 @@
+/**
+ * Types describing the shape of data returned by the mocked GraphQL API.
+ * These mirror `reference/schema.graphql` and, in turn, the original
+ * `datastore.js` dataset (see `reference/datastore.js`), reimplemented
+ * behind a GraphQL layer per the exercise instructions.
+ */
+
+export type PropertyType = "string" | "number" | "enumerated";
+
+export interface Property {
+ id: number;
+ name: string;
+ type: PropertyType;
+ /** Only present (and only meaningful) for `type: "enumerated"`. */
+ values?: string[];
+}
+
+export interface PropertyValue {
+ propertyId: number;
+ /** Numeric properties carry a number; string/enumerated carry a string. */
+ value: string | number;
+}
+
+export interface Product {
+ id: number;
+ /**
+ * Sparse by design: a product may omit a `PropertyValue` entirely for a
+ * given property (see products 3-5 in the mock dataset), which is what
+ * makes the `any` ("has any value") / `none` ("has no value") operators
+ * meaningful.
+ */
+ propertyValues: PropertyValue[];
+}
+
+export type OperatorId =
+ | "equals"
+ | "greater_than"
+ | "less_than"
+ | "any"
+ | "none"
+ | "in"
+ | "contains";
+
+export interface Operator {
+ id: OperatorId;
+ text: string;
+}
+
+/**
+ * The shape of the mock dataset in `src/mocks/data.ts` — no longer what a
+ * single query returns (see `ReferenceData` and the `products` query
+ * below), just a convenient bundle the MSW handlers slice per-operation.
+ */
+export interface CatalogData {
+ properties: Property[];
+ operators: Operator[];
+ products: Product[];
+}
+
+/** What the `ReferenceData` query returns: static, fetched once. */
+export interface ReferenceData {
+ properties: Property[];
+ operators: Operator[];
+}
+
+/**
+ * The `condition` variable for the `products(condition: ConditionInput)`
+ * query — structurally the same shape as `domain/filter`'s `Condition`,
+ * kept as an independent type here (rather than imported) since `domain/`
+ * already imports from `api/types` and this avoids a cycle.
+ */
+export interface ConditionInput {
+ propertyId: number;
+ operatorId: OperatorId;
+ value?: string | number | (string | number)[];
+}
diff --git a/src/api/useProducts.ts b/src/api/useProducts.ts
new file mode 100644
index 0000000..a930423
--- /dev/null
+++ b/src/api/useProducts.ts
@@ -0,0 +1,76 @@
+import { useEffect, useState } from "react";
+import { graphqlClient } from "./client";
+import { PRODUCTS_QUERY } from "./queries";
+import type { ConditionInput, Product } from "./types";
+import { isConditionComplete, type Condition } from "../domain/filter";
+
+export type ProductsState =
+ | { status: "loading"; products: Product[] }
+ | { status: "error"; error: Error; products: Product[] }
+ | { status: "success"; products: Product[] };
+
+/**
+ * `condition` is only sent to the server once it's actually complete (see
+ * `isConditionComplete`) — a property-only or value-less condition isn't
+ * something the server can filter on, and the client's intent while
+ * building one is still "show me everything".
+ */
+function toConditionInput(
+ condition: Condition | null,
+): ConditionInput | undefined {
+ return isConditionComplete(condition)
+ ? (condition as ConditionInput)
+ : undefined;
+}
+
+/**
+ * Fetches products for the current `condition` via the `products` query —
+ * the server does the filtering (see `src/mocks/handlers.ts`), the client
+ * just renders whatever comes back. Refetches whenever the *effective*
+ * condition (the one actually sent to the server) changes; building an
+ * incomplete condition doesn't trigger a new request since the server call
+ * would be identical to "no condition" either way.
+ *
+ * The previous products stay visible while a refetch is in flight, so the
+ * table doesn't flash empty on every keystroke.
+ */
+export function useProducts(condition: Condition | null): ProductsState {
+ const conditionInput = toConditionInput(condition);
+ const conditionKey = conditionInput ? JSON.stringify(conditionInput) : "";
+
+ const [state, setState] = useState({
+ status: "loading",
+ products: [],
+ });
+
+ useEffect(() => {
+ let isMounted = true;
+ setState((previous) => ({ status: "loading", products: previous.products }));
+
+ graphqlClient
+ .request<{ products: Product[] }>(PRODUCTS_QUERY, {
+ condition: conditionInput,
+ })
+ .then((data) => {
+ if (isMounted) setState({ status: "success", products: data.products });
+ })
+ .catch((error: unknown) => {
+ if (isMounted) {
+ setState((previous) => ({
+ status: "error",
+ error: error instanceof Error ? error : new Error(String(error)),
+ products: previous.products,
+ }));
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ // Re-run only when the effective condition (conditionKey) changes —
+ // conditionInput itself is derived fresh from it every render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [conditionKey]);
+
+ return state;
+}
diff --git a/src/api/useReferenceData.ts b/src/api/useReferenceData.ts
new file mode 100644
index 0000000..220f545
--- /dev/null
+++ b/src/api/useReferenceData.ts
@@ -0,0 +1,46 @@
+import { useEffect, useState } from "react";
+import { graphqlClient } from "./client";
+import { REFERENCE_DATA_QUERY } from "./queries";
+import type { ReferenceData } from "./types";
+
+export type ReferenceDataState =
+ | { status: "loading" }
+ | { status: "error"; error: Error }
+ | { status: "success"; data: ReferenceData };
+
+/**
+ * Fetches properties and operators from the GraphQL API exactly once on
+ * mount. This is static reference data for the session — unlike products,
+ * it never depends on the condition the user is building, so there's
+ * nothing to refetch.
+ */
+export function useReferenceData(): ReferenceDataState {
+ const [state, setState] = useState({
+ status: "loading",
+ });
+
+ useEffect(() => {
+ let isMounted = true;
+ setState({ status: "loading" });
+
+ graphqlClient
+ .request(REFERENCE_DATA_QUERY)
+ .then((data) => {
+ if (isMounted) setState({ status: "success", data });
+ })
+ .catch((error: unknown) => {
+ if (isMounted) {
+ setState({
+ status: "error",
+ error: error instanceof Error ? error : new Error(String(error)),
+ });
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ return state;
+}
diff --git a/src/components/ConditionEditor.tsx b/src/components/ConditionEditor.tsx
new file mode 100644
index 0000000..f704659
--- /dev/null
+++ b/src/components/ConditionEditor.tsx
@@ -0,0 +1,128 @@
+import type { Operator, OperatorId, Property } from "../api/types";
+import type { Condition, ConditionValue } from "../domain/filter";
+import { getValidOperatorIds, operatorTakesValue } from "../domain/operators";
+import { ValueInput } from "./ValueInput";
+
+interface ConditionEditorProps {
+ properties: Property[];
+ operators: Operator[];
+ condition: Condition | null;
+ onConditionChange: (condition: Condition | null) => void;
+ onClear: () => void;
+}
+
+/**
+ * The `[property] [operator] [value]` filter builder. The operator options
+ * are always filtered down to what's valid for the currently selected
+ * property's data type, and the value input adapts to both.
+ */
+export function ConditionEditor({
+ properties,
+ operators,
+ condition,
+ onConditionChange,
+ onClear,
+}: ConditionEditorProps) {
+ const selectedProperty =
+ properties.find((p) => p.id === condition?.propertyId) ?? null;
+
+ const availableOperators = selectedProperty
+ ? operators.filter((op) =>
+ getValidOperatorIds(selectedProperty.type).includes(op.id),
+ )
+ : [];
+
+ const handlePropertyChange = (propertyIdText: string) => {
+ if (propertyIdText === "") {
+ onConditionChange(null);
+ return;
+ }
+ const property = properties.find((p) => p.id === Number(propertyIdText));
+ if (!property) return;
+ // Reset the operator/value whenever the property changes, since the
+ // previous operator may no longer be valid for the new property's type.
+ const [defaultOperatorId] = getValidOperatorIds(property.type);
+ onConditionChange({
+ propertyId: property.id,
+ operatorId: defaultOperatorId,
+ value: undefined,
+ });
+ };
+
+ const handleOperatorChange = (operatorId: string) => {
+ if (!condition) return;
+ onConditionChange({
+ ...condition,
+ operatorId: operatorId as OperatorId,
+ value: undefined,
+ });
+ };
+
+ const handleValueChange = (value: ConditionValue) => {
+ if (!condition) return;
+ onConditionChange({ ...condition, value });
+ };
+
+ return (
+
+ Filter products
+
+
+ Property
+ handlePropertyChange(e.target.value)}
+ >
+ Select a property…
+ {properties.map((property) => (
+
+ {property.name}
+
+ ))}
+
+
+
+ {selectedProperty && condition && (
+
+ Operator
+ handleOperatorChange(e.target.value)}
+ >
+ {availableOperators.map((operator) => (
+
+ {operator.text}
+
+ ))}
+
+
+ )}
+
+ {selectedProperty &&
+ condition &&
+ operatorTakesValue(condition.operatorId) && (
+
+ Value
+
+
+ )}
+
+
+ Clear filter
+
+
+ );
+}
diff --git a/src/components/ProductList.tsx b/src/components/ProductList.tsx
new file mode 100644
index 0000000..dccf6de
--- /dev/null
+++ b/src/components/ProductList.tsx
@@ -0,0 +1,50 @@
+import type { Product, Property } from "../api/types";
+
+interface ProductListProps {
+ products: Product[];
+ properties: Property[];
+}
+
+/**
+ * Renders the (already-filtered) product list as a table with one column
+ * per property, driven entirely by whatever properties were fetched — no
+ * column is hardcoded to a specific property name.
+ */
+export function ProductList({ products, properties }: ProductListProps) {
+ const orderedProperties = [...properties].sort((a, b) => a.id - b.id);
+
+ return (
+
+
+ {products.length} product{products.length === 1 ? "" : "s"}
+
+
+
+
+
+ {orderedProperties.map((property) => (
+ {property.name}
+ ))}
+
+
+
+ {products.map((product) => (
+
+ {orderedProperties.map((property) => {
+ const propertyValue = product.propertyValues.find(
+ (pv) => pv.propertyId === property.id,
+ );
+ return (
+
+ {propertyValue ? String(propertyValue.value) : "—"}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/ValueInput.tsx b/src/components/ValueInput.tsx
new file mode 100644
index 0000000..8e062ca
--- /dev/null
+++ b/src/components/ValueInput.tsx
@@ -0,0 +1,159 @@
+import { useState } from "react";
+import type { OperatorId, Property } from "../api/types";
+import type { ConditionValue } from "../domain/filter";
+
+interface ValueInputProps {
+ property: Property;
+ operatorId: OperatorId;
+ value: ConditionValue;
+ onChange: (value: ConditionValue) => void;
+}
+
+function parseStringList(text: string): string[] {
+ return text
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+}
+
+function parseNumberList(text: string): number[] {
+ return text
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ .map(Number)
+ .filter((n) => !Number.isNaN(n));
+}
+
+function formatList(value: ConditionValue): string {
+ return Array.isArray(value) ? value.join(", ") : "";
+}
+
+/**
+ * Renders the value input appropriate for the selected property's data
+ * type and operator: no input for `any`/`none`, a `` (or a
+ * checkbox group for `in`) for enumerated properties, a number input for
+ * numeric properties, and a text input for strings (comma-separated for
+ * `in`, matching the README's `Headphones, Keys` example).
+ */
+export function ValueInput({
+ property,
+ operatorId,
+ value,
+ onChange,
+}: ValueInputProps) {
+ // Backing state for the `in` operator's free-text, comma-separated list
+ // inputs (number and string). The displayed text must be the raw string
+ // the user is typing, not a re-join of the already-parsed value array —
+ // parsing drops empty segments (trailing/in-progress commas, stray
+ // spaces), so deriving the input's `value` from the parsed array instead
+ // of from this local state would erase a comma the instant it's typed,
+ // making it look impossible to enter more than one value. `ConditionEditor`
+ // remounts this component (via `key`) whenever the property or operator
+ // changes, so this only needs to seed itself once per condition.
+ const [listText, setListText] = useState(() => formatList(value));
+
+ if (operatorId === "any" || operatorId === "none") {
+ return null;
+ }
+
+ if (property.type === "enumerated") {
+ const options = property.values ?? [];
+
+ if (operatorId === "in") {
+ const selected = Array.isArray(value) ? value.map(String) : [];
+ const toggle = (option: string) => {
+ const next = selected.includes(option)
+ ? selected.filter((v) => v !== option)
+ : [...selected, option];
+ onChange(next);
+ };
+ return (
+
+ {options.map((option) => (
+
+ toggle(option)}
+ />
+ {option}
+
+ ))}
+
+ );
+ }
+
+ return (
+ onChange(e.target.value)}
+ >
+
+ Select a value…
+
+ {options.map((option) => (
+
+ {option}
+
+ ))}
+
+ );
+ }
+
+ if (property.type === "number") {
+ if (operatorId === "in") {
+ return (
+ {
+ setListText(e.target.value);
+ onChange(parseNumberList(e.target.value));
+ }}
+ />
+ );
+ }
+ return (
+
+ onChange(e.target.value === "" ? undefined : Number(e.target.value))
+ }
+ />
+ );
+ }
+
+ // string
+ if (operatorId === "in") {
+ return (
+ {
+ setListText(e.target.value);
+ onChange(parseStringList(e.target.value));
+ }}
+ />
+ );
+ }
+ return (
+ onChange(e.target.value)}
+ />
+ );
+}
diff --git a/src/domain/filter.test.ts b/src/domain/filter.test.ts
new file mode 100644
index 0000000..1f4e062
--- /dev/null
+++ b/src/domain/filter.test.ts
@@ -0,0 +1,541 @@
+import { describe, expect, it } from "vitest";
+import type { Product, Property } from "../api/types";
+import {
+ evaluateCondition,
+ filterProducts,
+ isConditionComplete,
+} from "./filter";
+import { mockCatalog } from "../mocks/data";
+
+const { properties, products } = mockCatalog;
+
+const propertyName = properties.find((p) => p.id === 0)!; // string
+const propertyColor = properties.find((p) => p.id === 1)!; // string
+const propertyWeight = properties.find((p) => p.id === 2)!; // number
+const propertyCategory = properties.find((p) => p.id === 3)!; // enumerated
+const propertyWireless = properties.find((p) => p.id === 4)!; // enumerated, sparse
+
+function nameOf(product: Product): string {
+ return String(
+ product.propertyValues.find((pv) => pv.propertyId === 0)!.value,
+ );
+}
+
+describe("evaluateCondition", () => {
+ describe("equals", () => {
+ it("matches a string property exactly (README example: Name equals Headphones)", () => {
+ const headphones = products.find((p) => nameOf(p) === "Headphones")!;
+ const keyboard = products.find((p) => nameOf(p) === "Keyboard")!;
+ const condition = {
+ propertyId: 0,
+ operatorId: "equals" as const,
+ value: "Headphones",
+ };
+ expect(evaluateCondition(headphones, condition, propertyName)).toBe(true);
+ expect(evaluateCondition(keyboard, condition, propertyName)).toBe(false);
+ });
+
+ it("matches a number property numerically, not by string identity", () => {
+ const weighs5 = products.find((p) => nameOf(p) === "Headphones")!;
+ const condition = {
+ propertyId: 2,
+ operatorId: "equals" as const,
+ value: 5,
+ };
+ expect(evaluateCondition(weighs5, condition, propertyWeight)).toBe(true);
+ });
+
+ it("matches an enumerated property", () => {
+ const cup = products.find((p) => nameOf(p) === "Cup")!;
+ const condition = {
+ propertyId: 3,
+ operatorId: "equals" as const,
+ value: "kitchenware",
+ };
+ expect(evaluateCondition(cup, condition, propertyCategory)).toBe(true);
+ });
+
+ it("does not match when the property is missing on the product", () => {
+ const cup = products.find((p) => nameOf(p) === "Cup")!; // no wireless value
+ const condition = {
+ propertyId: 4,
+ operatorId: "equals" as const,
+ value: "true",
+ };
+ expect(evaluateCondition(cup, condition, propertyWireless)).toBe(false);
+ });
+ });
+
+ describe("greater_than / less_than (README example: Price/weight)", () => {
+ it("greater_than matches products whose numeric value is strictly greater", () => {
+ const condition = {
+ propertyId: 2,
+ operatorId: "greater_than" as const,
+ value: 4,
+ };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyWeight))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Hammer", "Headphones", "Keyboard"]);
+ });
+
+ it("less_than matches products whose numeric value is strictly less", () => {
+ const condition = {
+ propertyId: 2,
+ operatorId: "less_than" as const,
+ value: 4,
+ };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyWeight))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Cell Phone", "Cup", "Key"]);
+ });
+ });
+
+ describe("any / none (README example: Description any/none)", () => {
+ it("any matches only products that have the property at all", () => {
+ const condition = { propertyId: 4, operatorId: "any" as const };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyWireless))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Cell Phone", "Headphones", "Keyboard"]);
+ });
+
+ it("none matches only products missing the property entirely", () => {
+ const condition = { propertyId: 4, operatorId: "none" as const };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyWireless))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Cup", "Hammer", "Key"]);
+ });
+
+ it("any/none work the same way for properties every product has", () => {
+ const anyCondition = { propertyId: 0, operatorId: "any" as const };
+ const noneCondition = { propertyId: 0, operatorId: "none" as const };
+ for (const product of products) {
+ expect(evaluateCondition(product, anyCondition, propertyName)).toBe(
+ true,
+ );
+ expect(evaluateCondition(product, noneCondition, propertyName)).toBe(
+ false,
+ );
+ }
+ });
+ });
+
+ describe("in (README example: Name is any of Headphones, Keys)", () => {
+ it("matches a string property against a list of exact values", () => {
+ const condition = {
+ propertyId: 0,
+ operatorId: "in" as const,
+ value: ["Headphones", "Key"],
+ };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyName))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Headphones", "Key"]);
+ });
+
+ it("matches a number property against a list of numeric values", () => {
+ const condition = {
+ propertyId: 2,
+ operatorId: "in" as const,
+ value: [1, 19],
+ };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyWeight))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Hammer", "Key"]);
+ });
+
+ it("matches an enumerated property against a list of values", () => {
+ const condition = {
+ propertyId: 3,
+ operatorId: "in" as const,
+ value: ["tools", "kitchenware"],
+ };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyCategory))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Cup", "Hammer", "Key"]);
+ });
+
+ it("matches nothing when the value list is empty", () => {
+ const condition = { propertyId: 0, operatorId: "in" as const, value: [] };
+ const matches = products.filter((p) =>
+ evaluateCondition(p, condition, propertyName),
+ );
+ expect(matches).toHaveLength(0);
+ });
+ });
+
+ describe("contains (README worked example)", () => {
+ // The README's own example ("Headphones, Telephone, Cell Phone, Phone")
+ // includes products not present in this dataset, so it's verified
+ // against a small synthetic fixture in addition to the real dataset.
+ const syntheticProducts: Product[] = [
+ { id: 100, propertyValues: [{ propertyId: 0, value: "Headphones" }] },
+ { id: 101, propertyValues: [{ propertyId: 0, value: "Telephone" }] },
+ { id: 102, propertyValues: [{ propertyId: 0, value: "Cell Phone" }] },
+ { id: 103, propertyValues: [{ propertyId: 0, value: "Phone" }] },
+ { id: 104, propertyValues: [{ propertyId: 0, value: "Keyboard" }] },
+ ];
+
+ it("matches every product whose name contains the substring", () => {
+ const condition = {
+ propertyId: 0,
+ operatorId: "contains" as const,
+ value: "phone",
+ };
+ const matches = syntheticProducts
+ .filter((p) => evaluateCondition(p, condition, propertyName))
+ .map(nameOf);
+ expect(matches.sort()).toEqual([
+ "Cell Phone",
+ "Headphones",
+ "Phone",
+ "Telephone",
+ ]);
+ });
+
+ it("is case-insensitive", () => {
+ const condition = {
+ propertyId: 0,
+ operatorId: "contains" as const,
+ value: "PHONE",
+ };
+ expect(
+ evaluateCondition(syntheticProducts[0], condition, propertyName),
+ ).toBe(true);
+ });
+
+ it("applies against the real dataset too", () => {
+ const condition = {
+ propertyId: 0,
+ operatorId: "contains" as const,
+ value: "phone",
+ };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyName))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Cell Phone", "Headphones"]);
+ });
+
+ it("also works on non-Name string properties (color)", () => {
+ const condition = {
+ propertyId: 1,
+ operatorId: "contains" as const,
+ value: "lac",
+ };
+ const matches = products
+ .filter((p) => evaluateCondition(p, condition, propertyColor))
+ .map(nameOf);
+ expect(matches.sort()).toEqual(["Cell Phone", "Headphones"]);
+ });
+ });
+});
+
+describe("filterProducts", () => {
+ it("returns every product when there is no condition (clear filter)", () => {
+ expect(filterProducts(products, null, properties)).toEqual(products);
+ expect(filterProducts(products, undefined, properties)).toEqual(products);
+ });
+
+ it("returns every product when the condition's property can't be resolved", () => {
+ const condition = {
+ propertyId: 999,
+ operatorId: "any" as const,
+ };
+ expect(filterProducts(products, condition, properties)).toEqual(products);
+ });
+
+ it("filters down to matching products for a real condition", () => {
+ const condition = {
+ propertyId: 3,
+ operatorId: "equals" as const,
+ value: "electronics",
+ };
+ const result = filterProducts(products, condition, properties);
+ expect(result.map(nameOf).sort()).toEqual([
+ "Cell Phone",
+ "Headphones",
+ "Keyboard",
+ ]);
+ });
+});
+
+describe("isConditionComplete", () => {
+ it("is false for no condition", () => {
+ expect(isConditionComplete(null)).toBe(false);
+ expect(isConditionComplete(undefined)).toBe(false);
+ });
+
+ it("is false for a property-only condition awaiting a value-requiring operator's value", () => {
+ // Mirrors what ConditionEditor produces right after a property is
+ // selected: propertyId + a default operator, no value yet.
+ expect(
+ isConditionComplete({ propertyId: 0, operatorId: "equals" }),
+ ).toBe(false);
+ expect(
+ isConditionComplete({
+ propertyId: 0,
+ operatorId: "equals",
+ value: undefined,
+ }),
+ ).toBe(false);
+ });
+
+ it("is false for an empty-string value", () => {
+ expect(
+ isConditionComplete({
+ propertyId: 0,
+ operatorId: "contains",
+ value: "",
+ }),
+ ).toBe(false);
+ });
+
+ it("is false for 'in' with no values selected yet", () => {
+ expect(isConditionComplete({ propertyId: 0, operatorId: "in" })).toBe(
+ false,
+ );
+ expect(
+ isConditionComplete({ propertyId: 0, operatorId: "in", value: [] }),
+ ).toBe(false);
+ });
+
+ it("is true for 'any'/'none' as soon as property + operator are set (no value needed)", () => {
+ expect(isConditionComplete({ propertyId: 4, operatorId: "any" })).toBe(
+ true,
+ );
+ expect(isConditionComplete({ propertyId: 4, operatorId: "none" })).toBe(
+ true,
+ );
+ });
+
+ it("is true once a value-requiring operator has a value", () => {
+ expect(
+ isConditionComplete({
+ propertyId: 0,
+ operatorId: "equals",
+ value: "Headphones",
+ }),
+ ).toBe(true);
+ expect(
+ isConditionComplete({
+ propertyId: 2,
+ operatorId: "greater_than",
+ value: 0,
+ }),
+ ).toBe(true); // 0 is a valid, "complete" numeric value
+ expect(
+ isConditionComplete({
+ propertyId: 0,
+ operatorId: "in",
+ value: ["Headphones"],
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("filterProducts (condition completeness)", () => {
+ it("shows the full list when only a property is selected (no operator picked yet)", () => {
+ const condition = { propertyId: 0, operatorId: "equals" as const };
+ expect(filterProducts(products, condition, properties)).toEqual(products);
+ });
+
+ it("shows the full list while a value-requiring operator is missing its value", () => {
+ const conditions = [
+ { propertyId: 0, operatorId: "equals" as const },
+ { propertyId: 0, operatorId: "contains" as const, value: "" },
+ { propertyId: 2, operatorId: "greater_than" as const },
+ { propertyId: 2, operatorId: "less_than" as const },
+ { propertyId: 0, operatorId: "in" as const, value: [] },
+ ];
+ for (const condition of conditions) {
+ expect(filterProducts(products, condition, properties)).toEqual(
+ products,
+ );
+ }
+ });
+
+ it("filters immediately for 'any'/'none' once property + operator are set", () => {
+ const anyCondition = { propertyId: 4, operatorId: "any" as const };
+ const result = filterProducts(products, anyCondition, properties);
+ expect(result.map(nameOf).sort()).toEqual([
+ "Cell Phone",
+ "Headphones",
+ "Keyboard",
+ ]);
+ });
+
+ it("filters once the condition becomes fully set (property + operator + value)", () => {
+ const condition = {
+ propertyId: 0,
+ operatorId: "equals" as const,
+ value: "Headphones",
+ };
+ const result = filterProducts(products, condition, properties);
+ expect(result.map(nameOf)).toEqual(["Headphones"]);
+ });
+
+ it("restores the full list when a fully-set condition is cleared back to incomplete", () => {
+ const complete = {
+ propertyId: 0,
+ operatorId: "equals" as const,
+ value: "Headphones",
+ };
+ expect(filterProducts(products, complete, properties).map(nameOf)).toEqual(
+ ["Headphones"],
+ );
+
+ // Clearing the operator (as ConditionEditor's operator reset does)
+ // drops the value too, going back to an incomplete condition.
+ const clearedOperator = { propertyId: 0, operatorId: "equals" as const };
+ expect(
+ filterProducts(products, clearedOperator, properties),
+ ).toEqual(products);
+ });
+});
+
+describe("full property type x operator validity matrix (behavioral)", () => {
+ // These fixtures exist purely to exercise the exhaustive matrix from the
+ // README, independent of the `mockCatalog` data used above.
+ const stringProperty: Property = { id: 10, name: "Label", type: "string" };
+ const numberProperty: Property = { id: 11, name: "Count", type: "number" };
+ const enumProperty: Property = {
+ id: 12,
+ name: "Status",
+ type: "enumerated",
+ values: ["open", "closed"],
+ };
+
+ const withValue = (propertyId: number, value: string | number): Product => ({
+ id: 1,
+ propertyValues: [{ propertyId, value }],
+ });
+ const withoutValue = (): Product => ({ id: 2, propertyValues: [] });
+
+ it("string: equals, contains, in, any, none all behave correctly", () => {
+ const present = withValue(10, "hello world");
+ const absent = withoutValue();
+
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 10, operatorId: "equals", value: "hello world" },
+ stringProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 10, operatorId: "contains", value: "world" },
+ stringProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 10, operatorId: "in", value: ["nope", "hello world"] },
+ stringProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 10, operatorId: "any" },
+ stringProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ absent,
+ { propertyId: 10, operatorId: "none" },
+ stringProperty,
+ ),
+ ).toBe(true);
+ });
+
+ it("number: equals, greater_than, less_than, in, any, none all behave correctly", () => {
+ const present = withValue(11, 10);
+ const absent = withoutValue();
+
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 11, operatorId: "equals", value: 10 },
+ numberProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 11, operatorId: "greater_than", value: 5 },
+ numberProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 11, operatorId: "less_than", value: 20 },
+ numberProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 11, operatorId: "in", value: [1, 10] },
+ numberProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 11, operatorId: "any" },
+ numberProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ absent,
+ { propertyId: 11, operatorId: "none" },
+ numberProperty,
+ ),
+ ).toBe(true);
+ });
+
+ it("enumerated: equals, in, any, none all behave correctly (no contains/greater/less)", () => {
+ const present = withValue(12, "open");
+ const absent = withoutValue();
+
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 12, operatorId: "equals", value: "open" },
+ enumProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 12, operatorId: "in", value: ["closed", "open"] },
+ enumProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ present,
+ { propertyId: 12, operatorId: "any" },
+ enumProperty,
+ ),
+ ).toBe(true);
+ expect(
+ evaluateCondition(
+ absent,
+ { propertyId: 12, operatorId: "none" },
+ enumProperty,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/src/domain/filter.ts b/src/domain/filter.ts
new file mode 100644
index 0000000..7bea03a
--- /dev/null
+++ b/src/domain/filter.ts
@@ -0,0 +1,127 @@
+import type { OperatorId, Product, Property } from "../api/types";
+import { operatorTakesMultipleValues, operatorTakesValue } from "./operators";
+
+export type ConditionValue = string | number | (string | number)[] | undefined;
+
+export interface Condition {
+ propertyId: number;
+ operatorId: OperatorId;
+ value?: ConditionValue;
+}
+
+function getRawValue(
+ product: Product,
+ propertyId: number,
+): string | number | undefined {
+ return product.propertyValues.find((pv) => pv.propertyId === propertyId)
+ ?.value;
+}
+
+function toNumber(value: string | number): number {
+ return typeof value === "number" ? value : Number(value);
+}
+
+/**
+ * Evaluates a single condition against a single product for the property it
+ * targets. `property` must be the `Property` definition that
+ * `condition.propertyId` refers to (its `type` drives how values are
+ * compared — e.g. numeric vs. string comparison for `equals`/`in`).
+ */
+export function evaluateCondition(
+ product: Product,
+ condition: Condition,
+ property: Property,
+): boolean {
+ const raw = getRawValue(product, condition.propertyId);
+
+ switch (condition.operatorId) {
+ case "any":
+ return raw !== undefined;
+
+ case "none":
+ return raw === undefined;
+
+ case "equals": {
+ if (raw === undefined || condition.value === undefined) return false;
+ if (property.type === "number") {
+ return toNumber(raw) === toNumber(condition.value as string | number);
+ }
+ return String(raw) === String(condition.value);
+ }
+
+ case "contains": {
+ if (raw === undefined || condition.value === undefined) return false;
+ // Case-insensitive substring match — see SOLUTION.md "Assumptions".
+ return String(raw)
+ .toLowerCase()
+ .includes(String(condition.value).toLowerCase());
+ }
+
+ case "greater_than": {
+ if (raw === undefined || condition.value === undefined) return false;
+ return toNumber(raw) > toNumber(condition.value as string | number);
+ }
+
+ case "less_than": {
+ if (raw === undefined || condition.value === undefined) return false;
+ return toNumber(raw) < toNumber(condition.value as string | number);
+ }
+
+ case "in": {
+ if (raw === undefined) return false;
+ const candidates = Array.isArray(condition.value) ? condition.value : [];
+ if (candidates.length === 0) return false;
+ if (property.type === "number") {
+ const rawNumber = toNumber(raw);
+ return candidates.some((candidate) => toNumber(candidate) === rawNumber);
+ }
+ return candidates.some((candidate) => String(candidate) === String(raw));
+ }
+
+ default:
+ return false;
+ }
+}
+
+/**
+ * Whether `condition` has everything it needs to actually filter with:
+ * a property, an operator, and — for operators that take one (everything
+ * but `any`/`none`) — a value. Used to hold off filtering (showing the
+ * full list instead) while the user is still mid-way through building a
+ * condition, e.g. right after picking a property but before an operator
+ * needing a value has one entered.
+ */
+export function isConditionComplete(
+ condition: Condition | null | undefined,
+): boolean {
+ if (!condition) return false;
+ if (!operatorTakesValue(condition.operatorId)) return true;
+
+ if (operatorTakesMultipleValues(condition.operatorId)) {
+ return Array.isArray(condition.value) && condition.value.length > 0;
+ }
+
+ if (condition.value === undefined) return false;
+ if (typeof condition.value === "string") return condition.value.length > 0;
+ return true;
+}
+
+/**
+ * Filters `products` down to those matching `condition`, or returns them
+ * unchanged when there is no condition (the "clear filter" state), the
+ * condition isn't complete enough to evaluate yet (see
+ * `isConditionComplete`), or the condition's property can't be resolved.
+ */
+export function filterProducts(
+ products: Product[],
+ condition: Condition | null | undefined,
+ properties: Property[],
+): Product[] {
+ if (!condition) return products;
+ if (!isConditionComplete(condition)) return products;
+ const property = properties.find((p) => p.id === condition.propertyId);
+ if (!property) return products;
+ return products.filter((product) =>
+ evaluateCondition(product, condition, property),
+ );
+}
diff --git a/src/domain/operators.test.ts b/src/domain/operators.test.ts
new file mode 100644
index 0000000..bee013a
--- /dev/null
+++ b/src/domain/operators.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+import {
+ getValidOperatorIds,
+ isOperatorValidForType,
+ operatorTakesMultipleValues,
+ operatorTakesValue,
+} from "./operators";
+import type { OperatorId } from "../api/types";
+
+const ALL_OPERATORS: OperatorId[] = [
+ "equals",
+ "greater_than",
+ "less_than",
+ "any",
+ "none",
+ "in",
+ "contains",
+];
+
+describe("getValidOperatorIds", () => {
+ it("string: equals, contains, any, none, in — no greater_than/less_than", () => {
+ expect(new Set(getValidOperatorIds("string"))).toEqual(
+ new Set(["equals", "contains", "any", "none", "in"]),
+ );
+ });
+
+ it("number: equals, greater_than, less_than, any, none, in — no contains", () => {
+ expect(new Set(getValidOperatorIds("number"))).toEqual(
+ new Set(["equals", "greater_than", "less_than", "any", "none", "in"]),
+ );
+ });
+
+ it("enumerated: equals, any, none, in — no contains/greater_than/less_than", () => {
+ expect(new Set(getValidOperatorIds("enumerated"))).toEqual(
+ new Set(["equals", "any", "none", "in"]),
+ );
+ });
+});
+
+describe("isOperatorValidForType — full matrix", () => {
+ const expected: Record> = {
+ equals: { string: true, number: true, enumerated: true },
+ greater_than: { string: false, number: true, enumerated: false },
+ less_than: { string: false, number: true, enumerated: false },
+ any: { string: true, number: true, enumerated: true },
+ none: { string: true, number: true, enumerated: true },
+ in: { string: true, number: true, enumerated: true },
+ contains: { string: true, number: false, enumerated: false },
+ };
+
+ for (const operatorId of ALL_OPERATORS) {
+ for (const type of ["string", "number", "enumerated"] as const) {
+ it(`${operatorId} x ${type} => ${expected[operatorId][type]}`, () => {
+ expect(isOperatorValidForType(operatorId, type)).toBe(
+ expected[operatorId][type],
+ );
+ });
+ }
+ }
+});
+
+describe("operatorTakesValue", () => {
+ it("any and none take no value", () => {
+ expect(operatorTakesValue("any")).toBe(false);
+ expect(operatorTakesValue("none")).toBe(false);
+ });
+
+ it("every other operator takes a value", () => {
+ for (const operatorId of ALL_OPERATORS) {
+ if (operatorId === "any" || operatorId === "none") continue;
+ expect(operatorTakesValue(operatorId)).toBe(true);
+ }
+ });
+});
+
+describe("operatorTakesMultipleValues", () => {
+ it("only 'in' takes multiple values", () => {
+ for (const operatorId of ALL_OPERATORS) {
+ expect(operatorTakesMultipleValues(operatorId)).toBe(
+ operatorId === "in",
+ );
+ }
+ });
+});
diff --git a/src/domain/operators.ts b/src/domain/operators.ts
new file mode 100644
index 0000000..3413314
--- /dev/null
+++ b/src/domain/operators.ts
@@ -0,0 +1,41 @@
+import type { OperatorId, PropertyType } from "../api/types";
+
+/**
+ * The validity matrix from the exercise README:
+ *
+ * | Property Type | Valid Operators |
+ * | -------------- | ------------------------------------------------- |
+ * | string | equals, contains, any, none, in |
+ * | number | equals, greater_than, less_than, any, none, in |
+ * | enumerated | equals, any, none, in |
+ *
+ * `contains`, `greater_than`, and `less_than` never apply to `enumerated`;
+ * `greater_than`/`less_than` never apply to `string`; `contains` never
+ * applies to `number`.
+ */
+const VALID_OPERATORS_BY_TYPE: Record = {
+ string: ["equals", "contains", "any", "none", "in"],
+ number: ["equals", "greater_than", "less_than", "any", "none", "in"],
+ enumerated: ["equals", "any", "none", "in"],
+};
+
+export function getValidOperatorIds(type: PropertyType): OperatorId[] {
+ return VALID_OPERATORS_BY_TYPE[type];
+}
+
+export function isOperatorValidForType(
+ operatorId: OperatorId,
+ type: PropertyType,
+): boolean {
+ return VALID_OPERATORS_BY_TYPE[type].includes(operatorId);
+}
+
+/** Operators that take no value at all ("Has any value" / "Has no value"). */
+export function operatorTakesValue(operatorId: OperatorId): boolean {
+ return operatorId !== "any" && operatorId !== "none";
+}
+
+/** Operators whose value is a list of values ("Is any of"). */
+export function operatorTakesMultipleValues(operatorId: OperatorId): boolean {
+ return operatorId === "in";
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..f20f824
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,30 @@
+:root {
+ --text: #6b6375;
+ --text-h: #08060d;
+ --bg: #fff;
+ --border: #e5e4e7;
+
+ --sans: system-ui, "Segoe UI", Roboto, sans-serif;
+
+ font: 16px/145% var(--sans);
+ color-scheme: light dark;
+ color: var(--text-h);
+ background: var(--bg);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --text: #9ca3af;
+ --text-h: #f3f4f6;
+ --bg: #16171d;
+ --border: #2e303a;
+ }
+}
+
+body {
+ margin: 0;
+}
+
+h1 {
+ color: var(--text-h);
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..1528fcf
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,21 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App.tsx";
+
+async function enableMocking() {
+ // The mocked GraphQL API only needs to run in dev — production builds
+ // would point `GRAPHQL_ENDPOINT` at a real server, and tests wire up
+ // their own MSW `setupServer` instance (see `setupTests.ts`).
+ if (!import.meta.env.DEV) return;
+ const { worker } = await import("./mocks/browser");
+ return worker.start({ onUnhandledRequest: "bypass" });
+}
+
+enableMocking().then(() => {
+ createRoot(document.getElementById("root")!).render(
+
+
+ ,
+ );
+});
diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts
new file mode 100644
index 0000000..6030809
--- /dev/null
+++ b/src/mocks/browser.ts
@@ -0,0 +1,5 @@
+import { setupWorker } from "msw/browser";
+import { handlers } from "./handlers";
+
+/** Used only in the Vite dev server (see `main.tsx`). */
+export const worker = setupWorker(...handlers);
diff --git a/src/mocks/data.ts b/src/mocks/data.ts
new file mode 100644
index 0000000..eee8255
--- /dev/null
+++ b/src/mocks/data.ts
@@ -0,0 +1,97 @@
+import type { CatalogData } from "../api/types";
+
+/**
+ * Reimplements the dataset from `reference/datastore.js` behind the mocked
+ * GraphQL API (see `handlers.ts`). Field names are camelCased for GraphQL
+ * convention (`property_id` -> `propertyId`) but the ids, names, types, and
+ * values are otherwise identical — including products 3-5 (Cup, Key,
+ * Hammer) deliberately omitting a `wireless` property value, which is the
+ * dataset's test case for the `any`/`none` operators.
+ */
+export const mockCatalog: CatalogData = {
+ properties: [
+ { id: 0, name: "Product Name", type: "string" },
+ { id: 1, name: "color", type: "string" },
+ { id: 2, name: "weight (oz)", type: "number" },
+ {
+ id: 3,
+ name: "category",
+ type: "enumerated",
+ values: ["tools", "electronics", "kitchenware"],
+ },
+ { id: 4, name: "wireless", type: "enumerated", values: ["true", "false"] },
+ ],
+
+ operators: [
+ { id: "equals", text: "Equals" },
+ { id: "greater_than", text: "Is greater than" },
+ { id: "less_than", text: "Is less than" },
+ { id: "any", text: "Has any value" },
+ { id: "none", text: "Has no value" },
+ { id: "in", text: "Is any of" },
+ { id: "contains", text: "Contains" },
+ ],
+
+ products: [
+ {
+ id: 0,
+ propertyValues: [
+ { propertyId: 0, value: "Headphones" },
+ { propertyId: 1, value: "black" },
+ { propertyId: 2, value: 5 },
+ { propertyId: 3, value: "electronics" },
+ { propertyId: 4, value: "false" },
+ ],
+ },
+ {
+ id: 1,
+ propertyValues: [
+ { propertyId: 0, value: "Cell Phone" },
+ { propertyId: 1, value: "black" },
+ { propertyId: 2, value: 3 },
+ { propertyId: 3, value: "electronics" },
+ { propertyId: 4, value: "true" },
+ ],
+ },
+ {
+ id: 2,
+ propertyValues: [
+ { propertyId: 0, value: "Keyboard" },
+ { propertyId: 1, value: "grey" },
+ { propertyId: 2, value: 5 },
+ { propertyId: 3, value: "electronics" },
+ { propertyId: 4, value: "false" },
+ ],
+ },
+ {
+ id: 3,
+ propertyValues: [
+ { propertyId: 0, value: "Cup" },
+ { propertyId: 1, value: "white" },
+ { propertyId: 2, value: 3 },
+ { propertyId: 3, value: "kitchenware" },
+ // no `wireless` value on purpose
+ ],
+ },
+ {
+ id: 4,
+ propertyValues: [
+ { propertyId: 0, value: "Key" },
+ { propertyId: 1, value: "silver" },
+ { propertyId: 2, value: 1 },
+ { propertyId: 3, value: "tools" },
+ // no `wireless` value on purpose
+ ],
+ },
+ {
+ id: 5,
+ propertyValues: [
+ { propertyId: 0, value: "Hammer" },
+ { propertyId: 1, value: "brown" },
+ { propertyId: 2, value: 19 },
+ { propertyId: 3, value: "tools" },
+ // no `wireless` value on purpose
+ ],
+ },
+ ],
+};
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
new file mode 100644
index 0000000..4d8e43f
--- /dev/null
+++ b/src/mocks/handlers.ts
@@ -0,0 +1,40 @@
+import { graphql, HttpResponse } from "msw";
+import { mockCatalog } from "./data";
+import { filterProducts } from "../domain/filter";
+import type { ConditionInput, Product } from "../api/types";
+
+/**
+ * MSW intercepts GraphQL operations by name (regardless of endpoint URL).
+ * These handlers are shared between the browser worker (dev) and the node
+ * server (tests) so both exercise the exact same mocked API surface.
+ *
+ * Filtering now lives here, not in the client: `products` takes an
+ * optional `condition` and returns only the matching products, reusing
+ * `filterProducts`/`evaluateCondition` from `domain/filter` — the same
+ * matching semantics as before, just invoked at mock-response time instead
+ * of at client-render time. `filterProducts` already treats a missing
+ * condition as "return everything", so a request with no `condition`
+ * variable (or an explicit `null`) naturally returns the full list.
+ */
+export const handlers = [
+ graphql.query("ReferenceData", () => {
+ return HttpResponse.json({
+ data: {
+ properties: mockCatalog.properties,
+ operators: mockCatalog.operators,
+ },
+ });
+ }),
+
+ graphql.query<{ products: Product[] }, { condition?: ConditionInput | null }>(
+ "Products",
+ ({ variables }) => {
+ const products = filterProducts(
+ mockCatalog.products,
+ variables.condition ?? null,
+ mockCatalog.properties,
+ );
+ return HttpResponse.json({ data: { products } });
+ },
+ ),
+];
diff --git a/src/mocks/server.ts b/src/mocks/server.ts
new file mode 100644
index 0000000..dbab645
--- /dev/null
+++ b/src/mocks/server.ts
@@ -0,0 +1,5 @@
+import { setupServer } from "msw/node";
+import { handlers } from "./handlers";
+
+/** Used only under Vitest/node (see `setupTests.ts`). */
+export const server = setupServer(...handlers);
diff --git a/src/setupTests.ts b/src/setupTests.ts
new file mode 100644
index 0000000..760cd23
--- /dev/null
+++ b/src/setupTests.ts
@@ -0,0 +1,16 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterAll, afterEach, beforeAll } from "vitest";
+import { server } from "./mocks/server";
+
+// Enable API mocking before all tests, reset any request handlers added in
+// individual tests so they don't leak between tests, and clean up once
+// tests are done.
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+// With `test.globals: false`, RTL's own auto-cleanup (which relies on a
+// global `afterEach`) never registers, so each rendered component would
+// otherwise leak into the next test.
+afterEach(() => cleanup());
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..6830b6f
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "allowArbitraryExtensions": true,
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..8455dcb
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "module": "nodenext",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..41c1371
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,13 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/setupTests.ts"],
+ globals: false,
+ css: true,
+ },
+});