From b7ec314c8336f9d5399d686adae51004add1474f Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:04:40 +0700 Subject: [PATCH 01/21] docs: add design spec for problem 2 currency swap form Captures the brainstormed design for the fancy currency swap form: tech stack (Vite + React + TS + Tailwind + Headless UI + sonner + framer-motion), dark glassmorphism aesthetic, file structure, state machine with two-way binding rule, validation rules, mock submit flow, and a narrow unit-test plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-17-currency-swap-form-design.md | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-currency-swap-form-design.md diff --git a/docs/superpowers/specs/2026-05-17-currency-swap-form-design.md b/docs/superpowers/specs/2026-05-17-currency-swap-form-design.md new file mode 100644 index 0000000000..c03ee7aeb7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-currency-swap-form-design.md @@ -0,0 +1,289 @@ +# Currency Swap Form — Design + +**Date:** 2026-05-17 +**Location:** `code-challenge/src/problem2/` +**Status:** Approved, ready for implementation plan + +## Goal + +Replace the barebones HTML form template in `code-challenge/src/problem2/` with a polished, "fancy" single-page currency swap form. The form lets a user swap an amount of one token for another, using live USD prices from `https://interview.switcheo.com/prices.json` to compute exchange rates. Token icons come from the Switcheo `token-icons` GitHub repo. Submission is mocked with a loading delay followed by a success toast. + +Judging emphasis: usage intuitiveness and visual attractiveness. + +## Tech stack + +- **Build tool:** Vite (per task instruction) +- **Framework:** React 18 + TypeScript +- **Styling:** Tailwind CSS +- **Token picker:** `@headlessui/react` Combobox (built-in search, keyboard nav, ARIA) +- **Animation:** `framer-motion` (flip-button rotate, token-picker transitions) +- **Toast:** `sonner` (small, beautiful, accessible) +- **Testing:** `vitest` + `@testing-library/react` for unit-level coverage of pure logic only + +No state-management library and no form library — local React state with `useReducer` is sufficient for a form with two inputs. + +## Visual design + +**Aesthetic:** modern DEX dark glassmorphism (Uniswap/Jupiter family). Dark gradient page background with low-opacity blurred accent blobs, single centered glassy card. + +### Palette + +| Token | Value | +|---|---| +| `bg-base` | radial gradient `#0a0b1e` → `#1a0b2e` | +| `bg-blob-a` | `#6366f1` @ ~20% opacity, blurred | +| `bg-blob-b` | `#ec4899` @ ~20% opacity, blurred | +| `surface-card` | `rgba(255,255,255,0.04)` with `backdrop-blur-xl` | +| `border-card` | `rgba(255,255,255,0.08)`, 1px | +| `surface-input` | `rgba(255,255,255,0.03)` → `0.06` on focus | +| `text-primary` | `#f8fafc` | +| `text-secondary` | `#94a3b8` | +| `text-muted` | `#64748b` | +| `accent-gradient` | `from-indigo-500 via-purple-500 to-pink-500` | +| `success` | `#10b981` | +| `error` | `#f87171` text, `#ef4444/20` background tint | + +### Typography + +- Font family: `Inter` (variable) via Google Fonts +- Display weight 600/700 for amounts, 500 for labels, 400 for body text +- Amount inputs are ~2rem (32px), `font-variant-numeric: tabular-nums` so digits don't shift width + +### Layout + +- Centered card, max-width 480px, padding 24px, `rounded-3xl` +- Two `TokenAmountField` tiles stacked with 8px gap; a circular flip button is absolutely positioned over the gap +- Each field is its own `rounded-2xl` tile with a thin border + +### Components + +- **Flip button:** 44px circular, dark surface with thin border, rotates 180° on click via framer-motion (0.4s), accent-colored on hover +- **Token picker trigger:** pill-shaped, shows icon + symbol + ▾, hover lifts background opacity +- **Token picker popover:** modal/bottom-sheet on mobile, popover on desktop; sticky search input at top; scrollable list with icon, symbol, and USD price right-aligned; full keyboard nav from Headless UI +- **Submit button:** full-width, 56px tall, accent gradient background (muted when disabled), gentle scale-down on press (0.98), shows spinner + "Swapping…" while submitting +- **Rate summary row:** small monospace text above the submit button, format `1 ETH ≈ 3,200.00 USDC`, with a tiny clock icon + "Updated just now" +- **Toast (sonner):** bottom-center, dark surface, green check, message `Swapped {fromAmount} {fromSymbol} → {toAmount} {toSymbol}`, 4s auto-dismiss + +### Responsive + +- Mobile (< 480px): card spans full viewport with 16px outer padding; token picker becomes a full-height bottom sheet +- Desktop: card centered, ambient blobs visible behind + +### Accessibility + +- All interactive elements keyboard-reachable; focus ring is a 2px accent outline with 2px offset (not the default browser ring) +- Color contrast ≥ 4.5:1 for `text-secondary` on `surface-card` +- Token picker uses Headless UI's built-in ARIA combobox roles +- Inline errors associated to inputs via `aria-describedby` +- `prefers-reduced-motion` disables the flip rotate and toast slide + +## Architecture + +### File structure + +``` +code-challenge/src/problem2/ +├── index.html # Vite entry, mounts #root +├── package.json +├── vite.config.ts +├── tsconfig.json +├── tailwind.config.ts +├── postcss.config.js +├── README.md # how to run (npm i && npm run dev) +├── public/ +│ └── favicon.svg +└── src/ + ├── main.tsx # React root + mount + ├── App.tsx # page shell, background, mounts + ├── index.css # Tailwind directives + base layer (fonts, scrollbar) + ├── components/ + │ ├── SwapForm.tsx # the main card + reducer + │ ├── TokenAmountField.tsx # one row: label + amount input + token picker + USD value + │ ├── TokenPicker.tsx # Headless UI Combobox in a modal/popover + │ ├── TokenIcon.tsx # with fallback for missing svg + │ ├── FlipButton.tsx # round button between fields, rotate animation + │ ├── RateSummary.tsx # "1 ETH ≈ 3,200 USDC" + price freshness + │ └── SubmitButton.tsx # gradient button with loading + disabled states + ├── hooks/ + │ ├── usePrices.ts # fetch prices.json, dedupe, return Token[] + │ └── useSwapReducer.ts # the swap state machine + ├── lib/ + │ ├── tokens.ts # Token type, icon URL helper, formatters + │ ├── swap.ts # pure conversion math: amountIn → amountOut + │ └── validation.ts # validate(state) → { errors, isValid } + └── types.ts # shared TS types +``` + +### Existing template files + +`index.html`, `script.js`, and `style.css` from the original template are removed. The original `index.html` becomes the Vite entry (replaced wholesale, not edited). + +## Data layer + +### `Token` type + +```ts +type Token = { + symbol: string; // "ETH", "USDC", ... + price: number; // USD per 1 token, from latest entry + iconUrl: string; // https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens/.svg +}; +``` + +### `usePrices()` hook + +On mount: + +1. `fetch("https://interview.switcheo.com/prices.json")` +2. Group entries by `currency`, keep the one with the latest `date` +3. Drop any entry with a falsy `price` +4. Sort by `symbol` alphabetically +5. Returns `{ tokens: Token[], isLoading: boolean, error: Error | null, retry: () => void }` + +If the fetch fails, the form renders a centered error state with a "Retry" button instead of the swap UI. Without prices the form cannot function — no partial UI. + +## State machine + +### `SwapState` + +```ts +type SwapState = { + fromToken: Token | null; + toToken: Token | null; + fromAmount: string; // raw string from input, "" when empty + toAmount: string; // computed from fromAmount OR user-typed + lastEdited: "from" | "to"; // which side drives the conversion + touched: { from: boolean; to: boolean }; // for on-blur error display + status: "idle" | "submitting"; +}; +``` + +### Actions + +```ts +type Action = + | { type: "SET_FROM_TOKEN"; token: Token } + | { type: "SET_TO_TOKEN"; token: Token } + | { type: "SET_FROM_AMOUNT"; value: string } + | { type: "SET_TO_AMOUNT"; value: string } + | { type: "BLUR_FROM" } + | { type: "BLUR_TO" } + | { type: "FLIP" } + | { type: "SUBMIT_START" } + | { type: "RESET" }; +``` + +The toast is a side effect of the submit handler, not a reducer state — there is no dedicated "success" status because no UI references it. + +### Two-way binding rule + +`lastEdited` tracks which input the user is driving. The *other* input is re-derived on any change to amount or either token. + +- User types in From → `lastEdited = "from"`, To auto-fills from `convert(fromAmount, fromPrice, toPrice)` +- User picks a new From token → keep `lastEdited = "from"`, recompute To +- User clicks in To and types → `lastEdited = "to"`, From auto-fills going forward +- `FLIP` swaps `fromToken ↔ toToken` AND swaps `fromAmount ↔ toAmount`. `lastEdited` flips too (`"from" ↔ "to"`) + +### Default selection on first load + +Pre-select two well-known tokens (try ETH → USDC; fall back to the first two tokens in alphabetical order if either is missing from the price list). The form is never empty. + +## Conversion math (`lib/swap.ts`) + +```ts +// USD-bridged: amountOut = amountIn * (priceFrom / priceTo) +function convert(amountIn: number, fromPrice: number, toPrice: number): number; +``` + +Pure, fully unit-tested. Both prices are USD per token, so the rate is `fromPrice / toPrice`. + +## Validation (`lib/validation.ts`) + +Pure function: `validate(state: SwapState): { errors: Errors; isValid: boolean }`. + +| # | Rule | Error message | When shown | +|---|---|---|---| +| 1 | `fromToken` must be set | "Select a token to swap from" | on submit attempt only | +| 2 | `toToken` must be set | "Select a token to receive" | on submit attempt only | +| 3 | `fromToken !== toToken` | "Cannot swap a token for itself" | immediately after picking | +| 4 | `fromAmount` parses to a finite positive number | "Enter an amount greater than 0" | after blur or submit | +| 5 | `fromAmount` ≤ a sensible cap (e.g. 1e15) | "Amount is too large" | after blur | +| 6 | `fromAmount` decimal places ≤ 8 | "Up to 8 decimal places" | live (blocked at input level) | + +### Input-level guards (in `TokenAmountField.onChange` before dispatch) + +- Strip anything that isn't `[0-9.]` +- Allow only one `.` +- Hard-cap decimals at 8 (slice the string) +- Collapse leading zeros (`0005` → `5`, but `0.5` preserved) + +### Submit button enablement + +- Disabled when `!isValid` OR `status === "submitting"` +- Disabled state shows a `title` tooltip with the first error (e.g. "Select a token to receive") +- Visually muted (lower opacity, no gradient) when disabled + +### Error display + +- Inline error in a small red row directly under the offending field +- Token-mismatch (rule 3) shows as a banner below the flip button (relates to both fields) +- Errors clear automatically as the user fixes them — no need to re-blur + +## Mock submit flow + +The submit handler is an async function on the form: + +1. Capture the swap summary `{ fromAmount, fromSymbol, toAmount, toSymbol }` from current state (before reset clears it) +2. Dispatch `SUBMIT_START` → `status = "submitting"`, button shows spinner + "Swapping…", all inputs and pickers disabled +3. `await new Promise(r => setTimeout(r, 1500))` +4. Call `toast.success(...)` with the summary captured in step 1 +5. Dispatch `RESET` → clear `fromAmount` / `toAmount`, keep token selections (user is likely to swap more of the same pair), set `status` back to `"idle"`, re-enable inputs +6. Focus returns to the From amount input + +**No artificial failures** — happy path only, per design decision. + +### Toast content + +- Green check icon +- Title: "Swap complete" +- Body: `Swapped {fromAmount} {fromSymbol} → {toAmount} {toSymbol}`, amounts formatted to 6 significant figures +- 4s auto-dismiss, dismissible by click + +### Mid-submit edge cases + +- If user navigates away mid-submit, no cleanup needed (setTimeout completes locally; nothing to abort) +- Inputs and pickers are disabled while submitting, so the state cannot change underneath + +## Testing strategy + +Pure logic only; UI is verified manually. + +### Unit tests (`vitest`) + +- **`lib/swap.ts` — `convert()`**: zero amount, identical prices, fractional prices, very large amounts, very small prices +- **`lib/validation.ts`**: each rule above gets one positive and one negative case +- **`hooks/usePrices.ts`**: given a mocked response with duplicate entries, returns one deduped Token per symbol with the latest price; filters out tokens missing price; surfaces `error` on fetch rejection + +### Manual smoke test (documented in README) + +1. `npm install` then `npm run dev` +2. Pick ETH in From and USDC in To +3. Type `1` in From → see To populate with the equivalent USDC amount +4. Click flip → fields swap and `lastEdited` flips +5. Click Confirm Swap → spinner for ~1.5s, then green toast, then form resets +6. Try invalid states: same token both sides, zero amount, no tokens selected — confirm errors and disabled button + +No component tests and no e2e — disproportionate for a single form. + +## Out of scope + +Explicitly NOT included (to keep the task focused): + +- Wallet connection or balances (no MAX button) +- Slippage tolerance settings +- Price impact warnings +- Swap history +- Multi-hop routing or any real backend +- Price polling / auto-refresh +- Internationalization (English only) +- Theming (dark only) From 30b462a8ced6334052983792037b609b36628e0c Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:16:51 +0700 Subject: [PATCH 02/21] docs: add implementation plan for problem 2 currency swap form 14 bite-sized tasks covering bootstrap, Tailwind theme, vitest setup, shared types and helpers, TDD for swap math/validation/usePrices/ useSwapReducer, then the seven components, and the App shell with loading + error + toast wiring. Each task ends with a commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-17-currency-swap-form.md | 2373 +++++++++++++++++ 1 file changed, 2373 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-currency-swap-form.md diff --git a/docs/superpowers/plans/2026-05-17-currency-swap-form.md b/docs/superpowers/plans/2026-05-17-currency-swap-form.md new file mode 100644 index 0000000000..ed7ee89a87 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-currency-swap-form.md @@ -0,0 +1,2373 @@ +# Currency Swap Form — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the barebones HTML form in `code-challenge/src/problem2/` with a polished, dark-glassmorphism currency swap form built with Vite + React + TypeScript + Tailwind. Live exchange rates come from `https://interview.switcheo.com/prices.json`; submission is mocked with a 1.5s delay and a success toast. + +**Architecture:** Single-page React app. All swap state lives in a `useReducer` inside ``. Prices are fetched once on app load by a `usePrices()` hook and deduped to the latest entry per token. Pure logic (`convert`, `validate`) is unit-tested with vitest; components are verified manually. + +**Tech Stack:** Vite 5 · React 18 · TypeScript 5 · Tailwind CSS 3 · `@headlessui/react` v2 · `framer-motion` 11 · `sonner` 1.x · `vitest` + `@testing-library/react` + `jsdom` + +**Spec:** `docs/superpowers/specs/2026-05-17-currency-swap-form-design.md` + +**All paths in this plan are relative to the repo root `code-challenge/`.** Run all `npm` and `npx` commands from `src/problem2/` (the new project root) unless otherwise noted. Run `git` commands from anywhere in the repo. + +--- + +## Task 1: Bootstrap Vite + React + TypeScript project + +**Files:** +- Delete: `src/problem2/index.html`, `src/problem2/script.js`, `src/problem2/style.css` +- Create: `src/problem2/package.json`, `src/problem2/vite.config.ts`, `src/problem2/tsconfig.json`, `src/problem2/tsconfig.node.json`, `src/problem2/index.html`, `src/problem2/.gitignore` +- Create: `src/problem2/src/main.tsx`, `src/problem2/src/App.tsx`, `src/problem2/src/index.css` +- Create: `src/problem2/public/favicon.svg` + +- [ ] **Step 1: Delete the template files** + +```bash +rm src/problem2/index.html src/problem2/script.js src/problem2/style.css +``` + +- [ ] **Step 2: Create `src/problem2/package.json`** + +```json +{ + "name": "fancy-swap-form", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } +} +``` + +- [ ] **Step 3: Create `src/problem2/vite.config.ts`** + +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); +``` + +- [ ] **Step 4: Create `src/problem2/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +- [ ] **Step 5: Create `src/problem2/tsconfig.node.json`** + +```json +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} +``` + +- [ ] **Step 6: Create `src/problem2/.gitignore`** + +``` +node_modules +dist +.DS_Store +.vite +*.local +.env* +coverage +``` + +- [ ] **Step 7: Create `src/problem2/index.html`** + +```html + + + + + + + Fancy Swap + + +
+ + + +``` + +- [ ] **Step 8: Create `src/problem2/public/favicon.svg`** + +```svg + +``` + +- [ ] **Step 9: Create placeholder `src/problem2/src/main.tsx`** + +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); +``` + +- [ ] **Step 10: Create placeholder `src/problem2/src/App.tsx`** + +```tsx +export default function App() { + return
Fancy Swap — bootstrap OK
; +} +``` + +- [ ] **Step 11: Create placeholder `src/problem2/src/index.css`** + +```css +body { + margin: 0; + background: #0a0b1e; + font-family: ui-sans-serif, system-ui, sans-serif; +} +``` + +- [ ] **Step 12: Install and verify the dev server starts** + +```bash +cd src/problem2 && npm install && npm run dev +``` + +Expected: Vite prints `VITE v5.x.x ready in ...` and a local URL. Open the URL in a browser — should see "Fancy Swap — bootstrap OK" on a dark background. Stop the server with Ctrl-C. + +- [ ] **Step 13: Commit** + +```bash +git add src/problem2/.gitignore src/problem2/package.json src/problem2/vite.config.ts src/problem2/tsconfig.json src/problem2/tsconfig.node.json src/problem2/index.html src/problem2/public src/problem2/src +git commit -m "chore(problem2): bootstrap Vite + React + TS project" +``` + +Do **not** add `node_modules/`. Verify with `git status` — only the files listed above should be staged. + +--- + +## Task 2: Install runtime deps and configure Tailwind theme + +**Files:** +- Create: `src/problem2/tailwind.config.ts`, `src/problem2/postcss.config.js` +- Modify: `src/problem2/package.json` (dependencies), `src/problem2/src/index.css` (Tailwind directives + base layer) + +- [ ] **Step 1: Install runtime + Tailwind dependencies** + +```bash +cd src/problem2 && npm install @headlessui/react@^2.1.0 framer-motion@^11.3.0 sonner@^1.5.0 && npm install -D tailwindcss@^3.4.0 postcss@^8.4.0 autoprefixer@^10.4.0 +``` + +Expected: `package.json` updated, no errors. + +- [ ] **Step 2: Create `src/problem2/postcss.config.js`** + +```js +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +``` + +- [ ] **Step 3: Create `src/problem2/tailwind.config.ts` with the spec's palette** + +```ts +import type { Config } from "tailwindcss"; + +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + surface: { + card: "rgba(255, 255, 255, 0.04)", + input: "rgba(255, 255, 255, 0.03)", + "input-focus": "rgba(255, 255, 255, 0.06)", + }, + border: { + card: "rgba(255, 255, 255, 0.08)", + }, + ink: { + primary: "#f8fafc", + secondary: "#94a3b8", + muted: "#64748b", + }, + accent: { + start: "#6366f1", + mid: "#a855f7", + end: "#ec4899", + }, + success: "#10b981", + error: "#f87171", + }, + fontFamily: { + sans: ['"Inter"', "ui-sans-serif", "system-ui", "sans-serif"], + }, + backgroundImage: { + "accent-gradient": + "linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%)", + "page-radial": + "radial-gradient(ellipse at top left, #1a0b2e 0%, #0a0b1e 60%)", + }, + keyframes: { + "pulse-glow": { + "0%, 100%": { opacity: "0.5" }, + "50%": { opacity: "0.9" }, + }, + }, + animation: { + "pulse-glow": "pulse-glow 2.4s ease-in-out infinite", + }, + }, + }, + plugins: [], +} satisfies Config; +``` + +- [ ] **Step 4: Replace `src/problem2/src/index.css` with Tailwind directives + base styles** + +```css +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, body, #root { + min-height: 100%; + } + body { + @apply font-sans text-ink-primary bg-page-radial bg-fixed antialiased; + font-feature-settings: "cv11", "ss01"; + } + /* Subtle dark scrollbar */ + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); + } +} + +@layer utilities { + .tabular-nums { + font-variant-numeric: tabular-nums; + } + .focus-ring { + @apply outline-none focus-visible:ring-2 focus-visible:ring-accent-mid focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b1e]; + } +} +``` + +- [ ] **Step 5: Update `src/problem2/src/App.tsx` to verify Tailwind works** + +```tsx +export default function App() { + return ( +
+
+ Tailwind + dark theme OK +
+
+ ); +} +``` + +- [ ] **Step 6: Verify visually** + +```bash +cd src/problem2 && npm run dev +``` + +Expected: a glassy translucent card centered on a dark gradient. Stop with Ctrl-C. + +- [ ] **Step 7: Commit** + +```bash +git add src/problem2/package.json src/problem2/package-lock.json src/problem2/tailwind.config.ts src/problem2/postcss.config.js src/problem2/src/index.css src/problem2/src/App.tsx +git commit -m "chore(problem2): install runtime deps and configure Tailwind theme" +``` + +--- + +## Task 3: Set up vitest + Testing Library + +**Files:** +- Modify: `src/problem2/package.json` (devDependencies) +- Modify: `src/problem2/vite.config.ts` (add test config + JSDOM) +- Create: `src/problem2/src/test/setup.ts` +- Create: `src/problem2/src/test/smoke.test.ts` (sanity test, deleted at end of this task) + +- [ ] **Step 1: Install test deps** + +```bash +cd src/problem2 && npm install -D vitest@^2.0.0 jsdom@^25.0.0 @testing-library/react@^16.0.0 @testing-library/jest-dom@^6.4.0 @testing-library/user-event@^14.5.0 +``` + +- [ ] **Step 2: Replace `src/problem2/vite.config.ts` with test-enabled config** + +```ts +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + }, +}); +``` + +- [ ] **Step 3: Add vitest globals to TypeScript types — edit `src/problem2/tsconfig.json`** + +Find `"types": ["vite/client"]` and change it to: + +```json + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] +``` + +- [ ] **Step 4: Create `src/problem2/src/test/setup.ts`** + +```ts +import "@testing-library/jest-dom/vitest"; +``` + +- [ ] **Step 5: Write a smoke test to verify the runner works — create `src/problem2/src/test/smoke.test.ts`** + +```ts +import { describe, it, expect } from "vitest"; + +describe("vitest smoke", () => { + it("can run a trivial assertion", () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 6: Run the smoke test** + +```bash +cd src/problem2 && npm test +``` + +Expected: vitest reports `1 passed`. If it fails, fix config before continuing. + +- [ ] **Step 7: Delete the smoke test (we keep only meaningful tests)** + +```bash +rm src/problem2/src/test/smoke.test.ts +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/problem2/package.json src/problem2/package-lock.json src/problem2/vite.config.ts src/problem2/tsconfig.json src/problem2/src/test +git commit -m "chore(problem2): set up vitest + testing-library + jsdom" +``` + +--- + +## Task 4: Define shared types and token helpers + +**Files:** +- Create: `src/problem2/src/types.ts` +- Create: `src/problem2/src/lib/tokens.ts` + +No tests in this task — these are types and pure deterministic formatters. They will be exercised by tests in later tasks. + +- [ ] **Step 1: Create `src/problem2/src/types.ts`** + +```ts +export type Token = { + symbol: string; // "ETH", "USDC", ... + price: number; // USD per 1 token + iconUrl: string; // remote SVG URL +}; + +export type PriceEntry = { + currency: string; + date: string; // ISO 8601 + price: number; +}; + +export type SwapState = { + fromToken: Token | null; + toToken: Token | null; + fromAmount: string; + toAmount: string; + lastEdited: "from" | "to"; + touched: { from: boolean; to: boolean }; + status: "idle" | "submitting"; +}; + +export type ValidationErrors = { + fromToken?: string; + toToken?: string; + fromAmount?: string; + pair?: string; +}; + +export type ValidationResult = { + errors: ValidationErrors; + isValid: boolean; + firstError: string | null; +}; +``` + +- [ ] **Step 2: Create `src/problem2/src/lib/tokens.ts`** + +```ts +const ICON_BASE = + "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens"; + +export function iconUrlFor(symbol: string): string { + return `${ICON_BASE}/${symbol}.svg`; +} + +/** + * Format a number for display in the amount input. Strips trailing zeros, + * caps at 8 decimal places, no thousands separators (so the value can be + * round-tripped through parseFloat). + */ +export function formatAmount(n: number): string { + if (!Number.isFinite(n) || n === 0) return "0"; + const fixed = n.toFixed(8); + return fixed.replace(/\.?0+$/, "") || "0"; +} + +/** Format a USD value: $3,200.00 */ +export function formatUsd(n: number): string { + if (!Number.isFinite(n)) return "$0.00"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(n); +} + +/** Format a rate / display number with thousands separators, 6 sig figs. */ +export function formatDisplayNumber(n: number): string { + if (!Number.isFinite(n)) return "0"; + return new Intl.NumberFormat("en-US", { + maximumSignificantDigits: 6, + useGrouping: true, + }).format(n); +} + +/** + * Sanitize a raw user-typed amount string: + * - keep only digits and one decimal point + * - cap to 8 decimal places + * - collapse leading zeros ("0005" -> "5", "0.5" preserved) + */ +export function sanitizeAmount(raw: string): string { + // Strip everything except digits and dots + let s = raw.replace(/[^0-9.]/g, ""); + // Keep only first dot + const firstDot = s.indexOf("."); + if (firstDot !== -1) { + s = s.slice(0, firstDot + 1) + s.slice(firstDot + 1).replace(/\./g, ""); + } + // Cap to 8 decimal places + const dot = s.indexOf("."); + if (dot !== -1 && s.length - dot - 1 > 8) { + s = s.slice(0, dot + 1 + 8); + } + // Collapse leading zeros: "0005" -> "5", but keep "0.5" and "0" + if (s.length > 1 && s[0] === "0" && s[1] !== ".") { + s = s.replace(/^0+/, ""); + if (s === "" || s.startsWith(".")) s = "0" + s; + } + return s; +} +``` + +- [ ] **Step 3: Commit (no tests yet — sanitize gets tested in Task 6 via TokenAmountField behavior; formatters are obviously correct and used everywhere)** + +Actually — `sanitizeAmount` has enough branches to deserve unit tests. Add them now: + +Create `src/problem2/src/lib/tokens.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { sanitizeAmount, formatAmount, formatUsd, formatDisplayNumber } from "./tokens"; + +describe("sanitizeAmount", () => { + it("keeps a plain integer", () => { + expect(sanitizeAmount("123")).toBe("123"); + }); + it("keeps one decimal point", () => { + expect(sanitizeAmount("1.5")).toBe("1.5"); + }); + it("strips non-numeric characters", () => { + expect(sanitizeAmount("1a2b3c")).toBe("123"); + }); + it("collapses repeated decimal points", () => { + expect(sanitizeAmount("1.2.3")).toBe("1.23"); + }); + it("caps at 8 decimal places", () => { + expect(sanitizeAmount("0.123456789012")).toBe("0.12345678"); + }); + it("strips leading zeros from integers", () => { + expect(sanitizeAmount("0005")).toBe("5"); + }); + it("preserves 0.5", () => { + expect(sanitizeAmount("0.5")).toBe("0.5"); + }); + it("preserves a lone 0", () => { + expect(sanitizeAmount("0")).toBe("0"); + }); + it("rewrites a leading dot to 0.", () => { + expect(sanitizeAmount(".5")).toBe("0.5"); + }); + it("handles empty input", () => { + expect(sanitizeAmount("")).toBe(""); + }); +}); + +describe("formatAmount", () => { + it("returns '0' for zero", () => { + expect(formatAmount(0)).toBe("0"); + }); + it("returns '0' for NaN", () => { + expect(formatAmount(NaN)).toBe("0"); + }); + it("strips trailing zeros", () => { + expect(formatAmount(1.5)).toBe("1.5"); + expect(formatAmount(3200)).toBe("3200"); + }); + it("respects 8 decimal cap", () => { + expect(formatAmount(0.123456789)).toBe("0.12345679"); + }); +}); + +describe("formatUsd", () => { + it("formats with $ and two decimals", () => { + expect(formatUsd(3200)).toBe("$3,200.00"); + }); +}); + +describe("formatDisplayNumber", () => { + it("groups thousands", () => { + expect(formatDisplayNumber(3200)).toBe("3,200"); + }); +}); +``` + +- [ ] **Step 4: Run tests** + +```bash +cd src/problem2 && npm test +``` + +Expected: all tests pass (lone-dot test will fail before fix below if `sanitizeAmount` doesn't handle it — that's the TDD signal). + +If the `.5` test fails because the implementation produces `.5` instead of `0.5`, fix `sanitizeAmount` so a leading dot gets prefixed with `0`: + +After the leading-zero block, add this guard at the top of the function (right after the first dot handling): + +```ts +// Rewrite leading "." to "0." +if (s.startsWith(".")) s = "0" + s; +``` + +Then re-run tests to verify all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/problem2/src/types.ts src/problem2/src/lib/tokens.ts src/problem2/src/lib/tokens.test.ts +git commit -m "feat(problem2): add shared types and token helper functions" +``` + +--- + +## Task 5: Implement `lib/swap.ts` (TDD) + +**Files:** +- Create: `src/problem2/src/lib/swap.ts` +- Create: `src/problem2/src/lib/swap.test.ts` + +- [ ] **Step 1: Write the failing test — create `src/problem2/src/lib/swap.test.ts`** + +```ts +import { describe, it, expect } from "vitest"; +import { convert } from "./swap"; + +describe("convert", () => { + it("returns 0 for zero amountIn", () => { + expect(convert(0, 1, 1)).toBe(0); + }); + + it("returns the same amount when prices are identical", () => { + expect(convert(1.5, 100, 100)).toBe(1.5); + }); + + it("converts 1 ETH at $3200 to 3200 USDC at $1", () => { + expect(convert(1, 3200, 1)).toBe(3200); + }); + + it("converts 3200 USDC at $1 to 1 ETH at $3200", () => { + expect(convert(3200, 1, 3200)).toBe(1); + }); + + it("handles fractional prices", () => { + // 100 BLUR at $0.2 -> USD value 20, target token at $2 -> 10 of target + expect(convert(100, 0.2, 2)).toBe(10); + }); + + it("returns 0 when toPrice is 0 or invalid (guard)", () => { + expect(convert(10, 1, 0)).toBe(0); + expect(convert(10, 1, NaN)).toBe(0); + }); + + it("returns 0 when amountIn or fromPrice is invalid", () => { + expect(convert(NaN, 1, 1)).toBe(0); + expect(convert(10, NaN, 1)).toBe(0); + }); + + it("handles very large values without overflow", () => { + expect(convert(1e10, 3200, 1)).toBe(3.2e13); + }); + + it("handles very small values", () => { + expect(convert(1e-6, 3200, 1)).toBeCloseTo(3.2e-3, 10); + }); +}); +``` + +- [ ] **Step 2: Run test, verify it fails** + +```bash +cd src/problem2 && npm test -- swap +``` + +Expected: tests fail with "Cannot find module './swap'" or similar. + +- [ ] **Step 3: Implement `src/problem2/src/lib/swap.ts`** + +```ts +/** + * Convert an amount of one token to its USD-equivalent amount of another token. + * + * Both prices are USD per 1 token. The rate is fromPrice / toPrice. + * Returns 0 for any non-finite or zero-denominator input — callers display "0" + * in the receive field in that case, which is the right user-facing behavior. + */ +export function convert( + amountIn: number, + fromPrice: number, + toPrice: number, +): number { + if ( + !Number.isFinite(amountIn) || + !Number.isFinite(fromPrice) || + !Number.isFinite(toPrice) || + toPrice === 0 + ) { + return 0; + } + return amountIn * (fromPrice / toPrice); +} +``` + +- [ ] **Step 4: Run test, verify all pass** + +```bash +cd src/problem2 && npm test -- swap +``` + +Expected: all 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/problem2/src/lib/swap.ts src/problem2/src/lib/swap.test.ts +git commit -m "feat(problem2): add convert() with USD-bridged exchange math" +``` + +--- + +## Task 6: Implement `lib/validation.ts` (TDD) + +**Files:** +- Create: `src/problem2/src/lib/validation.ts` +- Create: `src/problem2/src/lib/validation.test.ts` + +- [ ] **Step 1: Write the failing test — create `src/problem2/src/lib/validation.test.ts`** + +```ts +import { describe, it, expect } from "vitest"; +import { validate } from "./validation"; +import type { SwapState, Token } from "../types"; + +const ETH: Token = { symbol: "ETH", price: 3200, iconUrl: "" }; +const USDC: Token = { symbol: "USDC", price: 1, iconUrl: "" }; + +function state(overrides: Partial = {}): SwapState { + return { + fromToken: ETH, + toToken: USDC, + fromAmount: "1", + toAmount: "3200", + lastEdited: "from", + touched: { from: true, to: true }, + status: "idle", + ...overrides, + }; +} + +describe("validate", () => { + it("returns valid for a well-formed state", () => { + const r = validate(state()); + expect(r.isValid).toBe(true); + expect(r.errors).toEqual({}); + expect(r.firstError).toBeNull(); + }); + + it("flags missing fromToken", () => { + const r = validate(state({ fromToken: null })); + expect(r.isValid).toBe(false); + expect(r.errors.fromToken).toMatch(/select a token/i); + }); + + it("flags missing toToken", () => { + const r = validate(state({ toToken: null })); + expect(r.isValid).toBe(false); + expect(r.errors.toToken).toMatch(/select a token/i); + }); + + it("flags identical tokens", () => { + const r = validate(state({ toToken: ETH })); + expect(r.isValid).toBe(false); + expect(r.errors.pair).toMatch(/cannot swap a token for itself/i); + }); + + it("flags empty amount", () => { + const r = validate(state({ fromAmount: "" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + }); + + it("flags zero amount", () => { + const r = validate(state({ fromAmount: "0" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + }); + + it("flags non-numeric amount", () => { + const r = validate(state({ fromAmount: "abc" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + }); + + it("flags absurdly large amount", () => { + const r = validate(state({ fromAmount: "1e16" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/too large/i); + }); + + it("firstError returns the first non-empty error", () => { + const r = validate(state({ fromToken: null, fromAmount: "" })); + expect(r.firstError).toMatch(/select a token/i); + }); + + it("validates the To side when lastEdited is 'to'", () => { + // When lastEdited is "to", the user is driving from the To side. + // The "amount" rule applies to the side the user typed in. + const r = validate( + state({ + fromAmount: "0", + toAmount: "0", + lastEdited: "to", + }), + ); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + // Same rule, same error key — the form still won't submit + }); +}); +``` + +- [ ] **Step 2: Run test, verify it fails** + +```bash +cd src/problem2 && npm test -- validation +``` + +Expected: failure. + +- [ ] **Step 3: Implement `src/problem2/src/lib/validation.ts`** + +```ts +import type { SwapState, ValidationResult, ValidationErrors } from "../types"; + +const MAX_AMOUNT = 1e15; + +export function validate(state: SwapState): ValidationResult { + const errors: ValidationErrors = {}; + + if (!state.fromToken) { + errors.fromToken = "Select a token to swap from"; + } + if (!state.toToken) { + errors.toToken = "Select a token to receive"; + } + if ( + state.fromToken && + state.toToken && + state.fromToken.symbol === state.toToken.symbol + ) { + errors.pair = "Cannot swap a token for itself"; + } + + // The "driving" amount is whichever side the user last typed in + const driverRaw = + state.lastEdited === "from" ? state.fromAmount : state.toAmount; + const driverNum = parseFloat(driverRaw); + + if ( + driverRaw.trim() === "" || + !Number.isFinite(driverNum) || + driverNum <= 0 + ) { + errors.fromAmount = "Enter an amount greater than 0"; + } else if (driverNum > MAX_AMOUNT) { + errors.fromAmount = "Amount is too large"; + } + + // firstError follows display priority: pair > tokens > amount + const order: (keyof ValidationErrors)[] = [ + "pair", + "fromToken", + "toToken", + "fromAmount", + ]; + const firstKey = order.find((k) => errors[k]); + const firstError = firstKey ? errors[firstKey]! : null; + + return { + errors, + isValid: Object.keys(errors).length === 0, + firstError, + }; +} +``` + +- [ ] **Step 4: Run tests, verify all pass** + +```bash +cd src/problem2 && npm test -- validation +``` + +Expected: all 10 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/problem2/src/lib/validation.ts src/problem2/src/lib/validation.test.ts +git commit -m "feat(problem2): add validate() for swap form state" +``` + +--- + +## Task 7: Implement `hooks/usePrices.ts` (TDD with mocked fetch) + +**Files:** +- Create: `src/problem2/src/hooks/usePrices.ts` +- Create: `src/problem2/src/hooks/usePrices.test.ts` + +- [ ] **Step 1: Write the failing test — create `src/problem2/src/hooks/usePrices.test.ts`** + +```ts +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { usePrices } from "./usePrices"; + +const SAMPLE = [ + // Duplicate currency: older entry, then newer entry. Newer should win. + { currency: "BUSD", date: "2023-08-29T07:10:40.000Z", price: 0.999 }, + { currency: "BUSD", date: "2023-08-29T07:11:40.000Z", price: 1.001 }, + // Single entries + { currency: "ETH", date: "2023-08-29T07:10:52.000Z", price: 1645.93 }, + { currency: "USDC", date: "2023-08-29T07:10:40.000Z", price: 1 }, + // No price -> dropped + { currency: "BROKEN", date: "2023-08-29T07:10:40.000Z", price: 0 }, + { currency: "NULLY", date: "2023-08-29T07:10:40.000Z", price: null }, +]; + +describe("usePrices", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(SAMPLE), + } as Response), + ), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("starts in loading state", () => { + const { result } = renderHook(() => usePrices()); + expect(result.current.isLoading).toBe(true); + expect(result.current.tokens).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it("returns deduped tokens sorted by symbol after fetch resolves", async () => { + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const symbols = result.current.tokens.map((t) => t.symbol); + expect(symbols).toEqual(["BUSD", "ETH", "USDC"]); + + const busd = result.current.tokens.find((t) => t.symbol === "BUSD")!; + expect(busd.price).toBe(1.001); // newer entry won + }); + + it("attaches an icon URL to each token", async () => { + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + const eth = result.current.tokens.find((t) => t.symbol === "ETH")!; + expect(eth.iconUrl).toBe( + "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens/ETH.svg", + ); + }); + + it("filters out tokens with falsy or non-finite prices", async () => { + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + const symbols = result.current.tokens.map((t) => t.symbol); + expect(symbols).not.toContain("BROKEN"); + expect(symbols).not.toContain("NULLY"); + }); + + it("surfaces a network error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.tokens).toEqual([]); + }); + + it("retry() re-runs the fetch", async () => { + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.reject(new Error("offline")); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(SAMPLE), + } as Response); + }), + ); + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBeInstanceOf(Error); + + result.current.retry(); + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.tokens.length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run test, verify it fails** + +```bash +cd src/problem2 && npm test -- usePrices +``` + +Expected: failure (no module yet). + +- [ ] **Step 3: Implement `src/problem2/src/hooks/usePrices.ts`** + +```ts +import { useCallback, useEffect, useState } from "react"; +import type { PriceEntry, Token } from "../types"; +import { iconUrlFor } from "../lib/tokens"; + +const PRICES_URL = "https://interview.switcheo.com/prices.json"; + +type UsePricesResult = { + tokens: Token[]; + isLoading: boolean; + error: Error | null; + retry: () => void; +}; + +function dedupe(entries: PriceEntry[]): Token[] { + const latest = new Map(); + for (const e of entries) { + if (!e || !e.currency) continue; + if (typeof e.price !== "number" || !Number.isFinite(e.price) || e.price <= 0) continue; + const existing = latest.get(e.currency); + if (!existing || new Date(e.date).getTime() > new Date(existing.date).getTime()) { + latest.set(e.currency, e); + } + } + return Array.from(latest.values()) + .sort((a, b) => a.currency.localeCompare(b.currency)) + .map((e) => ({ + symbol: e.currency, + price: e.price, + iconUrl: iconUrlFor(e.currency), + })); +} + +export function usePrices(): UsePricesResult { + const [tokens, setTokens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(null); + + fetch(PRICES_URL) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data: PriceEntry[]) => { + if (cancelled) return; + setTokens(dedupe(data)); + setIsLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err : new Error(String(err))); + setTokens([]); + setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [attempt]); + + const retry = useCallback(() => setAttempt((n) => n + 1), []); + + return { tokens, isLoading, error, retry }; +} +``` + +- [ ] **Step 4: Run tests, verify all pass** + +```bash +cd src/problem2 && npm test -- usePrices +``` + +Expected: all 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/problem2/src/hooks/usePrices.ts src/problem2/src/hooks/usePrices.test.ts +git commit -m "feat(problem2): add usePrices hook with dedupe and retry" +``` + +--- + +## Task 8: Implement `hooks/useSwapReducer.ts` (TDD) + +**Files:** +- Create: `src/problem2/src/hooks/useSwapReducer.ts` +- Create: `src/problem2/src/hooks/useSwapReducer.test.ts` + +This is the trickiest piece of pure logic. Tests come first. + +- [ ] **Step 1: Write the failing test — create `src/problem2/src/hooks/useSwapReducer.test.ts`** + +```ts +import { describe, it, expect } from "vitest"; +import { swapReducer, initialSwapState } from "./useSwapReducer"; +import type { Token } from "../types"; + +const ETH: Token = { symbol: "ETH", price: 3200, iconUrl: "" }; +const USDC: Token = { symbol: "USDC", price: 1, iconUrl: "" }; +const BTC: Token = { symbol: "BTC", price: 60000, iconUrl: "" }; + +const start = initialSwapState(ETH, USDC); + +describe("swapReducer", () => { + describe("initialSwapState", () => { + it("pre-fills tokens when both provided", () => { + expect(start.fromToken).toBe(ETH); + expect(start.toToken).toBe(USDC); + expect(start.fromAmount).toBe(""); + expect(start.toAmount).toBe(""); + expect(start.lastEdited).toBe("from"); + expect(start.status).toBe("idle"); + expect(start.touched).toEqual({ from: false, to: false }); + }); + + it("handles missing default tokens", () => { + const s = initialSwapState(null, null); + expect(s.fromToken).toBeNull(); + expect(s.toToken).toBeNull(); + }); + }); + + describe("SET_FROM_AMOUNT", () => { + it("sets fromAmount and recomputes toAmount", () => { + const s = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + expect(s.fromAmount).toBe("1"); + expect(s.toAmount).toBe("3200"); + expect(s.lastEdited).toBe("from"); + }); + + it("clears toAmount when fromAmount is empty", () => { + const s = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "" }); + expect(s.fromAmount).toBe(""); + expect(s.toAmount).toBe(""); + }); + }); + + describe("SET_TO_AMOUNT", () => { + it("sets toAmount and recomputes fromAmount, flips lastEdited", () => { + const s = swapReducer(start, { type: "SET_TO_AMOUNT", value: "3200" }); + expect(s.toAmount).toBe("3200"); + expect(s.fromAmount).toBe("1"); + expect(s.lastEdited).toBe("to"); + }); + }); + + describe("SET_FROM_TOKEN", () => { + it("changes fromToken and re-derives the non-driven side", () => { + const seeded = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + // seeded: 1 ETH -> 3200 USDC, lastEdited = "from" + const s = swapReducer(seeded, { type: "SET_FROM_TOKEN", token: BTC }); + // Now driving 1 BTC -> ? USDC + expect(s.fromToken).toBe(BTC); + expect(s.fromAmount).toBe("1"); + expect(s.toAmount).toBe("60000"); + }); + + it("when lastEdited='to', re-derives fromAmount instead", () => { + const seeded = swapReducer(start, { type: "SET_TO_AMOUNT", value: "60000" }); + // seeded: 18.75 ETH -> 60000 USDC, lastEdited = "to" + const s = swapReducer(seeded, { type: "SET_FROM_TOKEN", token: BTC }); + // Driving 60000 USDC -> 1 BTC + expect(s.toAmount).toBe("60000"); + expect(s.fromAmount).toBe("1"); + }); + }); + + describe("FLIP", () => { + it("swaps tokens, amounts, and lastEdited", () => { + const seeded = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + const s = swapReducer(seeded, { type: "FLIP" }); + expect(s.fromToken).toBe(USDC); + expect(s.toToken).toBe(ETH); + expect(s.fromAmount).toBe("3200"); + expect(s.toAmount).toBe("1"); + expect(s.lastEdited).toBe("to"); + }); + }); + + describe("BLUR_FROM / BLUR_TO", () => { + it("marks touched.from on BLUR_FROM", () => { + const s = swapReducer(start, { type: "BLUR_FROM" }); + expect(s.touched.from).toBe(true); + expect(s.touched.to).toBe(false); + }); + it("marks touched.to on BLUR_TO", () => { + const s = swapReducer(start, { type: "BLUR_TO" }); + expect(s.touched.to).toBe(true); + }); + }); + + describe("SUBMIT_START / RESET", () => { + it("SUBMIT_START sets status to submitting", () => { + const s = swapReducer(start, { type: "SUBMIT_START" }); + expect(s.status).toBe("submitting"); + }); + + it("RESET clears amounts but keeps token selections", () => { + const seeded = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + const submitted = swapReducer(seeded, { type: "SUBMIT_START" }); + const s = swapReducer(submitted, { type: "RESET" }); + expect(s.fromAmount).toBe(""); + expect(s.toAmount).toBe(""); + expect(s.fromToken).toBe(ETH); + expect(s.toToken).toBe(USDC); + expect(s.status).toBe("idle"); + expect(s.touched).toEqual({ from: false, to: false }); + }); + }); + + describe("SET_TO_TOKEN when one token is null", () => { + it("derives nothing if other side is missing", () => { + const empty = initialSwapState(null, null); + const s1 = swapReducer(empty, { type: "SET_FROM_AMOUNT", value: "1" }); + expect(s1.fromAmount).toBe("1"); + expect(s1.toAmount).toBe(""); + const s2 = swapReducer(s1, { type: "SET_FROM_TOKEN", token: ETH }); + // Still no toToken, so toAmount stays "" + expect(s2.toAmount).toBe(""); + }); + }); +}); +``` + +- [ ] **Step 2: Run test, verify it fails** + +```bash +cd src/problem2 && npm test -- useSwapReducer +``` + +Expected: failure (no module). + +- [ ] **Step 3: Implement `src/problem2/src/hooks/useSwapReducer.ts`** + +```ts +import { useReducer } from "react"; +import type { SwapState, Token } from "../types"; +import { convert } from "../lib/swap"; +import { formatAmount, sanitizeAmount } from "../lib/tokens"; + +export type SwapAction = + | { type: "SET_FROM_TOKEN"; token: Token } + | { type: "SET_TO_TOKEN"; token: Token } + | { type: "SET_FROM_AMOUNT"; value: string } + | { type: "SET_TO_AMOUNT"; value: string } + | { type: "BLUR_FROM" } + | { type: "BLUR_TO" } + | { type: "FLIP" } + | { type: "SUBMIT_START" } + | { type: "RESET" }; + +export function initialSwapState( + fromToken: Token | null, + toToken: Token | null, +): SwapState { + return { + fromToken, + toToken, + fromAmount: "", + toAmount: "", + lastEdited: "from", + touched: { from: false, to: false }, + status: "idle", + }; +} + +/** + * Re-derive the non-driven side from the driving side. + * Returns the next { fromAmount, toAmount } pair. + */ +function rederive(s: SwapState): { fromAmount: string; toAmount: string } { + const { fromToken, toToken, fromAmount, toAmount, lastEdited } = s; + + if (lastEdited === "from") { + if (!fromToken || !toToken || fromAmount === "") { + return { fromAmount, toAmount: "" }; + } + const n = parseFloat(fromAmount); + if (!Number.isFinite(n) || n === 0) { + return { fromAmount, toAmount: "0" }; + } + const out = convert(n, fromToken.price, toToken.price); + return { fromAmount, toAmount: formatAmount(out) }; + } else { + if (!fromToken || !toToken || toAmount === "") { + return { fromAmount: "", toAmount }; + } + const n = parseFloat(toAmount); + if (!Number.isFinite(n) || n === 0) { + return { fromAmount: "0", toAmount }; + } + const out = convert(n, toToken.price, fromToken.price); + return { fromAmount: formatAmount(out), toAmount }; + } +} + +export function swapReducer(state: SwapState, action: SwapAction): SwapState { + switch (action.type) { + case "SET_FROM_AMOUNT": { + const value = sanitizeAmount(action.value); + const next: SwapState = { + ...state, + fromAmount: value, + lastEdited: "from", + }; + const { toAmount } = rederive(next); + return { ...next, toAmount }; + } + case "SET_TO_AMOUNT": { + const value = sanitizeAmount(action.value); + const next: SwapState = { + ...state, + toAmount: value, + lastEdited: "to", + }; + const { fromAmount } = rederive(next); + return { ...next, fromAmount }; + } + case "SET_FROM_TOKEN": { + const next: SwapState = { ...state, fromToken: action.token }; + const { fromAmount, toAmount } = rederive(next); + return { ...next, fromAmount, toAmount }; + } + case "SET_TO_TOKEN": { + const next: SwapState = { ...state, toToken: action.token }; + const { fromAmount, toAmount } = rederive(next); + return { ...next, fromAmount, toAmount }; + } + case "FLIP": { + return { + ...state, + fromToken: state.toToken, + toToken: state.fromToken, + fromAmount: state.toAmount, + toAmount: state.fromAmount, + lastEdited: state.lastEdited === "from" ? "to" : "from", + }; + } + case "BLUR_FROM": + return { ...state, touched: { ...state.touched, from: true } }; + case "BLUR_TO": + return { ...state, touched: { ...state.touched, to: true } }; + case "SUBMIT_START": + return { ...state, status: "submitting" }; + case "RESET": + return { + ...state, + fromAmount: "", + toAmount: "", + touched: { from: false, to: false }, + status: "idle", + }; + default: + return state; + } +} + +export function useSwapReducer( + fromToken: Token | null, + toToken: Token | null, +) { + return useReducer(swapReducer, initialSwapState(fromToken, toToken)); +} +``` + +- [ ] **Step 4: Run tests, verify all pass** + +```bash +cd src/problem2 && npm test -- useSwapReducer +``` + +Expected: all tests pass. If the FLIP test fails because `lastEdited` doesn't flip correctly, re-check the case — it should be `state.lastEdited === "from" ? "to" : "from"`. + +- [ ] **Step 5: Run the full test suite** + +```bash +cd src/problem2 && npm test +``` + +Expected: all suites pass (tokens, swap, validation, usePrices, useSwapReducer). + +- [ ] **Step 6: Commit** + +```bash +git add src/problem2/src/hooks/useSwapReducer.ts src/problem2/src/hooks/useSwapReducer.test.ts +git commit -m "feat(problem2): add useSwapReducer with two-way binding" +``` + +--- + +## Task 9: Build `TokenIcon` and `FlipButton` components + +**Files:** +- Create: `src/problem2/src/components/TokenIcon.tsx` +- Create: `src/problem2/src/components/FlipButton.tsx` + +No unit tests — these are display components verified visually in Task 14. + +- [ ] **Step 1: Create `src/problem2/src/components/TokenIcon.tsx`** + +```tsx +import { useState } from "react"; + +type Props = { + symbol: string; + src: string; + size?: number; + className?: string; +}; + +export function TokenIcon({ symbol, src, size = 28, className = "" }: Props) { + const [failed, setFailed] = useState(false); + const initial = symbol.slice(0, 1).toUpperCase(); + + if (failed) { + return ( + + {initial} + + ); + } + return ( + {symbol} setFailed(true)} + loading="lazy" + /> + ); +} +``` + +- [ ] **Step 2: Create `src/problem2/src/components/FlipButton.tsx`** + +```tsx +import { motion } from "framer-motion"; + +type Props = { + onClick: () => void; + disabled?: boolean; +}; + +export function FlipButton({ onClick, disabled }: Props) { + return ( + + + + + + + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/problem2/src/components/TokenIcon.tsx src/problem2/src/components/FlipButton.tsx +git commit -m "feat(problem2): add TokenIcon and FlipButton components" +``` + +--- + +## Task 10: Build `TokenPicker` component + +**Files:** +- Create: `src/problem2/src/components/TokenPicker.tsx` + +Uses Headless UI's `Dialog` + `Combobox` so the picker is keyboard-accessible and the panel is full-screen-friendly on mobile. + +- [ ] **Step 1: Create `src/problem2/src/components/TokenPicker.tsx`** + +```tsx +import { Fragment, useState } from "react"; +import { + Combobox, + ComboboxInput, + ComboboxOption, + ComboboxOptions, + Dialog, + DialogPanel, + Transition, + TransitionChild, +} from "@headlessui/react"; +import type { Token } from "../types"; +import { TokenIcon } from "./TokenIcon"; +import { formatUsd } from "../lib/tokens"; + +type Props = { + isOpen: boolean; + onClose: () => void; + tokens: Token[]; + selected: Token | null; + exclude?: Token | null; + onSelect: (token: Token) => void; +}; + +export function TokenPicker({ + isOpen, + onClose, + tokens, + selected, + exclude, + onSelect, +}: Props) { + const [query, setQuery] = useState(""); + + const filtered = tokens + .filter((t) => !exclude || t.symbol !== exclude.symbol) + .filter((t) => t.symbol.toLowerCase().includes(query.toLowerCase())); + + function handleClose() { + setQuery(""); + onClose(); + } + + function handleSelect(t: Token | null) { + if (!t) return; + onSelect(t); + handleClose(); + } + + return ( + + + +
+ + +
+ + + +
+

Select a token

+ setQuery(e.target.value)} + displayValue={() => query} + className=" + focus-ring w-full rounded-2xl border border-border-card + bg-surface-input px-4 py-3 text-ink-primary + placeholder:text-ink-muted + " + /> +
+ + {filtered.length === 0 && ( +
+ No tokens match "{query}" +
+ )} + {filtered.map((token) => ( + + {({ focus, selected: isSelected }) => ( +
+
+ +
+
+ {token.symbol} +
+
+ {formatUsd(token.price)} +
+
+
+ {isSelected && ( + + )} +
+ )} +
+ ))} +
+
+
+
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/problem2/src/components/TokenPicker.tsx +git commit -m "feat(problem2): add TokenPicker dialog with searchable combobox" +``` + +--- + +## Task 11: Build `TokenAmountField` component + +**Files:** +- Create: `src/problem2/src/components/TokenAmountField.tsx` + +- [ ] **Step 1: Create `src/problem2/src/components/TokenAmountField.tsx`** + +```tsx +import { forwardRef, useId, useState } from "react"; +import type { Token } from "../types"; +import { formatUsd, sanitizeAmount } from "../lib/tokens"; +import { TokenIcon } from "./TokenIcon"; +import { TokenPicker } from "./TokenPicker"; + +type Props = { + label: string; + amount: string; + token: Token | null; + tokens: Token[]; + otherToken: Token | null; + onAmountChange: (value: string) => void; + onTokenChange: (token: Token) => void; + onBlur?: () => void; + error?: string; + disabled?: boolean; + placeholder?: string; +}; + +export const TokenAmountField = forwardRef( + function TokenAmountField( + { + label, + amount, + token, + tokens, + otherToken, + onAmountChange, + onTokenChange, + onBlur, + error, + disabled, + placeholder = "0.0", + }, + ref, + ) { + const [pickerOpen, setPickerOpen] = useState(false); + const inputId = useId(); + const errorId = useId(); + + const numericAmount = parseFloat(amount); + const usdValue = + token && Number.isFinite(numericAmount) ? numericAmount * token.price : 0; + + return ( +
+ + +
+ onAmountChange(sanitizeAmount(e.target.value))} + onBlur={onBlur} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} + className=" + focus-ring min-w-0 flex-1 bg-transparent text-2xl font-semibold + text-ink-primary tabular-nums placeholder:text-ink-muted/60 + outline-none + " + /> + + +
+ +
+ + {token && usdValue > 0 ? `≈ ${formatUsd(usdValue)}` : " "} + + {error && ( + + {error} + + )} +
+ + setPickerOpen(false)} + tokens={tokens} + selected={token} + exclude={otherToken} + onSelect={onTokenChange} + /> +
+ ); + }, +); +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/problem2/src/components/TokenAmountField.tsx +git commit -m "feat(problem2): add TokenAmountField with token picker integration" +``` + +--- + +## Task 12: Build `RateSummary` and `SubmitButton` components + +**Files:** +- Create: `src/problem2/src/components/RateSummary.tsx` +- Create: `src/problem2/src/components/SubmitButton.tsx` + +- [ ] **Step 1: Create `src/problem2/src/components/RateSummary.tsx`** + +```tsx +import type { Token } from "../types"; +import { formatDisplayNumber } from "../lib/tokens"; + +type Props = { + fromToken: Token | null; + toToken: Token | null; +}; + +export function RateSummary({ fromToken, toToken }: Props) { + if (!fromToken || !toToken || fromToken.symbol === toToken.symbol) { + return null; + } + const rate = fromToken.price / toToken.price; + return ( +
+ + + + + + Updated just now + + + 1 {fromToken.symbol} ≈ {formatDisplayNumber(rate)} {toToken.symbol} + +
+ ); +} +``` + +- [ ] **Step 2: Create `src/problem2/src/components/SubmitButton.tsx`** + +```tsx +import { motion } from "framer-motion"; + +type Props = { + isSubmitting: boolean; + disabled: boolean; + disabledReason?: string | null; +}; + +export function SubmitButton({ isSubmitting, disabled, disabledReason }: Props) { + const label = isSubmitting ? "Swapping…" : "Confirm Swap"; + return ( + + + {isSubmitting && ( + + + + + )} + {label} + + + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/problem2/src/components/RateSummary.tsx src/problem2/src/components/SubmitButton.tsx +git commit -m "feat(problem2): add RateSummary and SubmitButton components" +``` + +--- + +## Task 13: Assemble `SwapForm` with submit flow + toast + +**Files:** +- Create: `src/problem2/src/components/SwapForm.tsx` + +- [ ] **Step 1: Create `src/problem2/src/components/SwapForm.tsx`** + +```tsx +import { useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { Token } from "../types"; +import { useSwapReducer } from "../hooks/useSwapReducer"; +import { validate } from "../lib/validation"; +import { formatDisplayNumber } from "../lib/tokens"; +import { TokenAmountField } from "./TokenAmountField"; +import { FlipButton } from "./FlipButton"; +import { RateSummary } from "./RateSummary"; +import { SubmitButton } from "./SubmitButton"; + +type Props = { + tokens: Token[]; +}; + +const PREFERRED_FROM = "ETH"; +const PREFERRED_TO = "USDC"; + +function pickDefaults(tokens: Token[]): { + from: Token | null; + to: Token | null; +} { + if (tokens.length === 0) return { from: null, to: null }; + const from = + tokens.find((t) => t.symbol === PREFERRED_FROM) ?? tokens[0] ?? null; + const to = + tokens.find((t) => t.symbol === PREFERRED_TO && t.symbol !== from?.symbol) ?? + tokens.find((t) => t.symbol !== from?.symbol) ?? + null; + return { from, to }; +} + +export function SwapForm({ tokens }: Props) { + const defaults = useMemo(() => pickDefaults(tokens), [tokens]); + const [state, dispatch] = useSwapReducer(defaults.from, defaults.to); + const [submitAttempted, setSubmitAttempted] = useState(false); + const fromInputRef = useRef(null); + + const validation = useMemo(() => validate(state), [state]); + + // Touch-aware errors: only show after blur OR after submit attempt + const showFromAmountError = + (state.touched.from || state.touched.to || submitAttempted) && + !!validation.errors.fromAmount; + const showTokenErrors = submitAttempted; + const showPairError = !!validation.errors.pair; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitAttempted(true); + if (!validation.isValid || state.status === "submitting") return; + + // Capture for the toast before reset clears the state + const summary = { + fromAmount: formatDisplayNumber(parseFloat(state.fromAmount)), + fromSymbol: state.fromToken!.symbol, + toAmount: formatDisplayNumber(parseFloat(state.toAmount)), + toSymbol: state.toToken!.symbol, + }; + + dispatch({ type: "SUBMIT_START" }); + await new Promise((r) => setTimeout(r, 1500)); + toast.success("Swap complete", { + description: `Swapped ${summary.fromAmount} ${summary.fromSymbol} → ${summary.toAmount} ${summary.toSymbol}`, + }); + dispatch({ type: "RESET" }); + setSubmitAttempted(false); + fromInputRef.current?.focus(); + } + + const isSubmitting = state.status === "submitting"; + const submitDisabled = !validation.isValid || isSubmitting; + + return ( +
+
+

Swap

+
+ +
+ dispatch({ type: "SET_FROM_AMOUNT", value: v })} + onTokenChange={(t) => dispatch({ type: "SET_FROM_TOKEN", token: t })} + onBlur={() => dispatch({ type: "BLUR_FROM" })} + error={ + state.lastEdited === "from" && showFromAmountError + ? validation.errors.fromAmount + : showTokenErrors + ? validation.errors.fromToken + : undefined + } + disabled={isSubmitting} + /> + +
+
+ dispatch({ type: "FLIP" })} + disabled={isSubmitting} + /> +
+
+ + dispatch({ type: "SET_TO_AMOUNT", value: v })} + onTokenChange={(t) => dispatch({ type: "SET_TO_TOKEN", token: t })} + onBlur={() => dispatch({ type: "BLUR_TO" })} + error={ + state.lastEdited === "to" && showFromAmountError + ? validation.errors.fromAmount + : showTokenErrors + ? validation.errors.toToken + : undefined + } + disabled={isSubmitting} + /> +
+ + {showPairError && ( +
+ {validation.errors.pair} +
+ )} + +
+ +
+ +
+ +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/problem2/src/components/SwapForm.tsx +git commit -m "feat(problem2): assemble SwapForm with submit flow" +``` + +--- + +## Task 14: Build App shell, wire up sonner toast, add error and loading states, write README + +**Files:** +- Modify: `src/problem2/src/App.tsx` +- Modify: `src/problem2/src/main.tsx` +- Create: `src/problem2/README.md` + +- [ ] **Step 1: Replace `src/problem2/src/App.tsx`** + +```tsx +import { usePrices } from "./hooks/usePrices"; +import { SwapForm } from "./components/SwapForm"; + +function LoadingCard() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +function ErrorCard({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+ + + + +
+

Couldn't load prices

+

{message}

+ +
+ ); +} + +function BackgroundBlobs() { + return ( +
+
+
+
+ ); +} + +export default function App() { + const { tokens, isLoading, error, retry } = usePrices(); + + return ( + <> + +
+ {isLoading && } + {!isLoading && error && ( + + )} + {!isLoading && !error && tokens.length > 0 && } + {!isLoading && !error && tokens.length === 0 && ( + + )} +
+ + ); +} +``` + +- [ ] **Step 2: Replace `src/problem2/src/main.tsx` to add ``** + +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Toaster } from "sonner"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + , +); +``` + +- [ ] **Step 3: Create `src/problem2/README.md`** + +```markdown +# Fancy Swap (Problem 2) + +A polished currency swap form built with **Vite + React + TypeScript + Tailwind**. + +## Run it + +```bash +npm install +npm run dev +``` + +Then open the URL Vite prints (usually http://localhost:5173). + +## Other scripts + +```bash +npm run build # type-check + production build +npm run preview # serve the production build locally +npm test # run the unit tests once +npm run test:watch # vitest in watch mode +``` + +## Tech + +- **Vite 5** — build & dev server +- **React 18 + TypeScript** — UI +- **Tailwind CSS** — styling +- **@headlessui/react** — accessible combobox + dialog primitives for the token picker +- **framer-motion** — flip button + button micro-interactions +- **sonner** — success toast +- **vitest** + **@testing-library/react** + **jsdom** — unit tests + +## Manual smoke test + +1. Wait for prices to load (1–2 s; you'll see a skeleton card) +2. ETH → USDC are pre-selected. Type `1` in "You pay" → "You receive" auto-fills with the USDC equivalent +3. Click the round flip button between the two fields → values and tokens swap +4. Tap a token pill → searchable picker opens (try typing "USD") +5. Try to swap the same token both sides → see the inline pair error +6. Try a zero / empty amount → button is muted, hover tooltip explains why +7. Click **Confirm Swap** → spinner ~1.5 s → green toast appears at bottom; amounts reset, tokens kept + +## Data sources + +- Prices: `https://interview.switcheo.com/prices.json` (fetched once, deduped to the most recent entry per symbol; tokens without prices are dropped) +- Token icons: `https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens/.svg` (falls back to a gradient initial chip on load failure) + +## Spec & plan + +See `../../docs/superpowers/specs/2026-05-17-currency-swap-form-design.md` and `../../docs/superpowers/plans/2026-05-17-currency-swap-form.md`. +``` + +- [ ] **Step 4: Build + typecheck** + +```bash +cd src/problem2 && npm run build +``` + +Expected: a clean build, no TypeScript errors, output written to `dist/`. If there are unused-variable errors (e.g. an unused `useEffect` import or `submitAttempted` warning), remove the dead code rather than disabling the lint rule. + +- [ ] **Step 5: Run all tests one final time** + +```bash +cd src/problem2 && npm test +``` + +Expected: all tests pass across `tokens`, `swap`, `validation`, `usePrices`, `useSwapReducer`. + +- [ ] **Step 6: Manual visual verification** + +```bash +cd src/problem2 && npm run dev +``` + +Walk through the manual smoke test in the README. Check on a narrow viewport (≤ 480 px wide) — the token picker should slide up from the bottom, the card should fit, no horizontal scroll. + +If anything is visually off, fix inline before committing. + +- [ ] **Step 7: Commit** + +```bash +git add src/problem2/src/App.tsx src/problem2/src/main.tsx src/problem2/README.md +git commit -m "feat(problem2): wire up App shell, toast, error and loading states + README" +``` + +- [ ] **Step 8: Final repo-state check** + +```bash +git status +git log --oneline -15 +``` + +Expected: working tree clean (aside from anything you intentionally left unstaged from other problems). The last ~14 commits should narrate the build cleanly from bootstrap to working app. + +--- + +## Spec coverage check + +Every section of the design spec is implemented by the tasks above: + +| Spec section | Task(s) | +|---|---| +| Tech stack | 1, 2, 3 | +| Palette / typography / layout / accessibility | 2, 9–14 | +| File structure | every task | +| `Token` type | 4 | +| `usePrices()` hook + dedupe + retry + loading + error | 7, 14 | +| `SwapState` + actions | 4, 8 | +| Two-way binding rule (`lastEdited`) | 8 | +| Default selection (ETH → USDC fallback) | 13 | +| `lib/swap.ts` convert math | 5 | +| `lib/validation.ts` rules 1–6 | 4 (input-level guards via `sanitizeAmount`), 6 (rules 1–5) | +| Submit button disabled-with-tooltip | 12, 13 | +| Inline + on-blur error display | 11, 13 | +| Mock submit flow with capture-before-reset | 13 | +| Toast content | 13, 14 | +| Mid-submit input disabling | 11, 13 | +| Loading skeleton + error retry UI | 14 | +| Unit tests for swap, validation, usePrices | 5, 6, 7 (and reducer in 8) | +| README with manual smoke test | 14 | + +No gaps. From 7ba8553501aa234ccab204bf3377ede37ed30aba Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:22:52 +0700 Subject: [PATCH 03/21] chore(problem2): bootstrap Vite + React + TS project --- src/problem2/.gitignore | 7 + src/problem2/index.html | 38 +- src/problem2/package-lock.json | 1768 +++++++++++++++++++++++++++++++ src/problem2/package.json | 24 + src/problem2/public/favicon.svg | 1 + src/problem2/script.js | 0 src/problem2/src/App.tsx | 3 + src/problem2/src/index.css | 5 + src/problem2/src/main.tsx | 10 + src/problem2/style.css | 8 - src/problem2/tsconfig.json | 22 + src/problem2/tsconfig.node.json | 11 + src/problem2/vite.config.ts | 6 + 13 files changed, 1869 insertions(+), 34 deletions(-) create mode 100644 src/problem2/.gitignore create mode 100644 src/problem2/package-lock.json create mode 100644 src/problem2/package.json create mode 100644 src/problem2/public/favicon.svg delete mode 100644 src/problem2/script.js create mode 100644 src/problem2/src/App.tsx create mode 100644 src/problem2/src/index.css create mode 100644 src/problem2/src/main.tsx delete mode 100644 src/problem2/style.css create mode 100644 src/problem2/tsconfig.json create mode 100644 src/problem2/tsconfig.node.json create mode 100644 src/problem2/vite.config.ts diff --git a/src/problem2/.gitignore b/src/problem2/.gitignore new file mode 100644 index 0000000000..2a384ba69f --- /dev/null +++ b/src/problem2/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.DS_Store +.vite +*.local +.env* +coverage diff --git a/src/problem2/index.html b/src/problem2/index.html index 4058a68bff..2cdfca048c 100644 --- a/src/problem2/index.html +++ b/src/problem2/index.html @@ -1,27 +1,13 @@ - - - - - Fancy Form - - - - - - - - -
-
Swap
- - - - - - - -
- - - + + + + + + + Fancy Swap + + +
+ + diff --git a/src/problem2/package-lock.json b/src/problem2/package-lock.json new file mode 100644 index 0000000000..3b6d69cc98 --- /dev/null +++ b/src/problem2/package-lock.json @@ -0,0 +1,1768 @@ +{ + "name": "fancy-swap-form", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fancy-swap-form", + "version": "0.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "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/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/problem2/package.json b/src/problem2/package.json new file mode 100644 index 0000000000..692b1a5110 --- /dev/null +++ b/src/problem2/package.json @@ -0,0 +1,24 @@ +{ + "name": "fancy-swap-form", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } +} diff --git a/src/problem2/public/favicon.svg b/src/problem2/public/favicon.svg new file mode 100644 index 0000000000..dc2367ccb3 --- /dev/null +++ b/src/problem2/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/src/problem2/script.js b/src/problem2/script.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem2/src/App.tsx b/src/problem2/src/App.tsx new file mode 100644 index 0000000000..6a4289bb14 --- /dev/null +++ b/src/problem2/src/App.tsx @@ -0,0 +1,3 @@ +export default function App() { + return
Fancy Swap — bootstrap OK
; +} diff --git a/src/problem2/src/index.css b/src/problem2/src/index.css new file mode 100644 index 0000000000..3986c425f2 --- /dev/null +++ b/src/problem2/src/index.css @@ -0,0 +1,5 @@ +body { + margin: 0; + background: #0a0b1e; + font-family: ui-sans-serif, system-ui, sans-serif; +} diff --git a/src/problem2/src/main.tsx b/src/problem2/src/main.tsx new file mode 100644 index 0000000000..27481e022f --- /dev/null +++ b/src/problem2/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/problem2/style.css b/src/problem2/style.css deleted file mode 100644 index 915af91c72..0000000000 --- a/src/problem2/style.css +++ /dev/null @@ -1,8 +0,0 @@ -body { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - min-width: 360px; - font-family: Arial, Helvetica, sans-serif; -} diff --git a/src/problem2/tsconfig.json b/src/problem2/tsconfig.json new file mode 100644 index 0000000000..b775710426 --- /dev/null +++ b/src/problem2/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/src/problem2/tsconfig.node.json b/src/problem2/tsconfig.node.json new file mode 100644 index 0000000000..97ede7ee6f --- /dev/null +++ b/src/problem2/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/src/problem2/vite.config.ts b/src/problem2/vite.config.ts new file mode 100644 index 0000000000..081c8d9f69 --- /dev/null +++ b/src/problem2/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); From 08cfc29fed3aea4d05b10dc63ffe73fb371f5399 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:26:11 +0700 Subject: [PATCH 04/21] chore(problem2): gitignore tsc build artifacts --- src/problem2/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/problem2/.gitignore b/src/problem2/.gitignore index 2a384ba69f..fc4fa97fcb 100644 --- a/src/problem2/.gitignore +++ b/src/problem2/.gitignore @@ -5,3 +5,6 @@ dist *.local .env* coverage +*.tsbuildinfo +vite.config.js +vite.config.d.ts From 893f175f6994164992ab64d94d7684eb5a3d818c Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:28:10 +0700 Subject: [PATCH 05/21] chore(problem2): install runtime deps and configure Tailwind theme --- src/problem2/package-lock.json | 1331 ++++++++++++++++++++++++++++++- src/problem2/package.json | 8 +- src/problem2/postcss.config.js | 6 + src/problem2/src/App.tsx | 8 +- src/problem2/src/index.css | 39 +- src/problem2/tailwind.config.ts | 50 ++ 6 files changed, 1399 insertions(+), 43 deletions(-) create mode 100644 src/problem2/postcss.config.js create mode 100644 src/problem2/tailwind.config.ts diff --git a/src/problem2/package-lock.json b/src/problem2/package-lock.json index 3b6d69cc98..99f4ae9ea9 100644 --- a/src/problem2/package-lock.json +++ b/src/problem2/package-lock.json @@ -8,17 +8,36 @@ "name": "fancy-swap-form", "version": "0.0.0", "dependencies": { + "@headlessui/react": "^2.2.10", + "framer-motion": "^11.18.2", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "sonner": "^1.7.4" }, "devDependencies": { "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.14", + "tailwindcss": "^3.4.19", "typescript": "^5.5.4", "vite": "^5.4.0" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -692,6 +711,106 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", + "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz", + "integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -742,6 +861,82 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-aria/focus": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz", + "integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", + "integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1138,6 +1333,42 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1239,6 +1470,83 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.30", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", @@ -1252,6 +1560,32 @@ "node": ">=6.0.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1286,6 +1620,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -1307,6 +1651,63 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1314,6 +1715,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1339,6 +1753,20 @@ } } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.357", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", @@ -1346,6 +1774,16 @@ "dev": true, "license": "ISC" }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1395,63 +1833,285 @@ "node": ">=6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=8.6.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 6" } }, - "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==", - "license": "MIT" + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" } }, + "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==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1474,6 +2134,45 @@ "yallist": "^3.0.2" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1481,6 +2180,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1507,6 +2218,43 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1514,6 +2262,39 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -1543,6 +2324,161 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1555,6 +2491,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-aria": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz", + "integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.46.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -1578,6 +2535,79 @@ "node": ">=0.10.0" } }, + "node_modules/react-stately": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", + "integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -1623,6 +2653,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1642,6 +2696,16 @@ "semver": "bin/semver.js" } }, + "node_modules/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1652,6 +2716,183 @@ "node": ">=0.10.0" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/tinyglobby/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/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1697,6 +2938,22 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/src/problem2/package.json b/src/problem2/package.json index 692b1a5110..a61c256c96 100644 --- a/src/problem2/package.json +++ b/src/problem2/package.json @@ -11,13 +11,19 @@ "test:watch": "vitest" }, "dependencies": { + "@headlessui/react": "^2.2.10", + "framer-motion": "^11.18.2", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "sonner": "^1.7.4" }, "devDependencies": { "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.14", + "tailwindcss": "^3.4.19", "typescript": "^5.5.4", "vite": "^5.4.0" } diff --git a/src/problem2/postcss.config.js b/src/problem2/postcss.config.js new file mode 100644 index 0000000000..2aa7205d4b --- /dev/null +++ b/src/problem2/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/problem2/src/App.tsx b/src/problem2/src/App.tsx index 6a4289bb14..92ec9de094 100644 --- a/src/problem2/src/App.tsx +++ b/src/problem2/src/App.tsx @@ -1,3 +1,9 @@ export default function App() { - return
Fancy Swap — bootstrap OK
; + return ( +
+
+ Tailwind + dark theme OK +
+
+ ); } diff --git a/src/problem2/src/index.css b/src/problem2/src/index.css index 3986c425f2..ac1dc760f6 100644 --- a/src/problem2/src/index.css +++ b/src/problem2/src/index.css @@ -1,5 +1,36 @@ -body { - margin: 0; - background: #0a0b1e; - font-family: ui-sans-serif, system-ui, sans-serif; +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, body, #root { + min-height: 100%; + } + body { + @apply font-sans text-ink-primary bg-page-radial bg-fixed antialiased; + font-feature-settings: "cv11", "ss01"; + } + /* Subtle dark scrollbar */ + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); + } +} + +@layer utilities { + .tabular-nums { + font-variant-numeric: tabular-nums; + } + .focus-ring { + @apply outline-none focus-visible:ring-2 focus-visible:ring-accent-mid focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b1e]; + } } diff --git a/src/problem2/tailwind.config.ts b/src/problem2/tailwind.config.ts new file mode 100644 index 0000000000..a493c66336 --- /dev/null +++ b/src/problem2/tailwind.config.ts @@ -0,0 +1,50 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + surface: { + card: "rgba(255, 255, 255, 0.04)", + input: "rgba(255, 255, 255, 0.03)", + "input-focus": "rgba(255, 255, 255, 0.06)", + }, + border: { + card: "rgba(255, 255, 255, 0.08)", + }, + ink: { + primary: "#f8fafc", + secondary: "#94a3b8", + muted: "#64748b", + }, + accent: { + start: "#6366f1", + mid: "#a855f7", + end: "#ec4899", + }, + success: "#10b981", + error: "#f87171", + }, + fontFamily: { + sans: ['"Inter"', "ui-sans-serif", "system-ui", "sans-serif"], + }, + backgroundImage: { + "accent-gradient": + "linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%)", + "page-radial": + "radial-gradient(ellipse at top left, #1a0b2e 0%, #0a0b1e 60%)", + }, + keyframes: { + "pulse-glow": { + "0%, 100%": { opacity: "0.5" }, + "50%": { opacity: "0.9" }, + }, + }, + animation: { + "pulse-glow": "pulse-glow 2.4s ease-in-out infinite", + }, + }, + }, + plugins: [], +} satisfies Config; From 940cdf5cd456eef606444d5020968928eae9c32e Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:32:02 +0700 Subject: [PATCH 06/21] chore(problem2): set up vitest + testing-library + jsdom --- src/problem2/package-lock.json | 1442 +++++++++++++++++++++++++++++++- src/problem2/package.json | 7 +- src/problem2/src/test/setup.ts | 1 + src/problem2/tsconfig.json | 2 +- src/problem2/vite.config.ts | 6 + 5 files changed, 1438 insertions(+), 20 deletions(-) create mode 100644 src/problem2/src/test/setup.ts diff --git a/src/problem2/package-lock.json b/src/problem2/package-lock.json index 99f4ae9ea9..713c69b139 100644 --- a/src/problem2/package-lock.json +++ b/src/problem2/package-lock.json @@ -15,16 +15,28 @@ "sonner": "^1.7.4" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.5.0", + "jsdom": "^25.0.1", "postcss": "^8.5.14", "tailwindcss": "^3.4.19", "typescript": "^5.5.4", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^2.1.9" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -38,6 +50,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -272,6 +305,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -320,6 +363,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1369,6 +1527,104 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "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": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "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": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "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/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1470,6 +1726,154 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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", + "peer": true, + "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/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1510,6 +1914,33 @@ "node": ">=10" } }, + "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1620,6 +2051,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1651,18 +2106,45 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, @@ -1698,6 +2180,19 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1715,6 +2210,13 @@ "dev": true, "license": "MIT" }, + "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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1728,6 +2230,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1735,6 +2258,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1753,6 +2290,43 @@ } } }, + "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1767,6 +2341,29 @@ "dev": true, "license": "MIT" }, + "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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.357", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", @@ -1774,6 +2371,29 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1784,6 +2404,42 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1833,6 +2489,26 @@ "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.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1886,6 +2562,23 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1962,6 +2655,45 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1975,6 +2707,48 @@ "node": ">=10.13.0" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -1988,6 +2762,70 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.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-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2050,6 +2888,13 @@ "node": ">=0.12.0" } }, + "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/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2066,6 +2911,47 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2124,6 +3010,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2134,6 +3027,37 @@ "yallist": "^3.0.2" } }, + "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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2141,21 +3065,54 @@ "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">=8.6" + "node": ">= 0.6" + } + }, + "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/motion-dom": { @@ -2228,6 +3185,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2248,6 +3212,19 @@ "node": ">= 6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2255,6 +3232,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2458,6 +3452,32 @@ "dev": true, "license": "MIT" }, + "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/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2525,6 +3545,14 @@ "react": "^18.3.1" } }, + "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/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2575,6 +3603,20 @@ "node": ">=8.10.0" } }, + "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/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2653,6 +3695,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2677,6 +3726,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "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.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2696,6 +3765,13 @@ "semver": "bin/semver.js" } }, + "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/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -2716,6 +3792,33 @@ "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/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "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/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2752,6 +3855,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -2819,6 +3929,20 @@ "node": ">=0.8" } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2867,6 +3991,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2880,6 +4054,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3014,6 +4214,212 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/src/problem2/package.json b/src/problem2/package.json index a61c256c96..13b2815ef8 100644 --- a/src/problem2/package.json +++ b/src/problem2/package.json @@ -18,13 +18,18 @@ "sonner": "^1.7.4" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.5.0", + "jsdom": "^25.0.1", "postcss": "^8.5.14", "tailwindcss": "^3.4.19", "typescript": "^5.5.4", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^2.1.9" } } diff --git a/src/problem2/src/test/setup.ts b/src/problem2/src/test/setup.ts new file mode 100644 index 0000000000..f149f27ae4 --- /dev/null +++ b/src/problem2/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/src/problem2/tsconfig.json b/src/problem2/tsconfig.json index b775710426..7184cbdd5b 100644 --- a/src/problem2/tsconfig.json +++ b/src/problem2/tsconfig.json @@ -15,7 +15,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "types": ["vite/client"] + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/src/problem2/vite.config.ts b/src/problem2/vite.config.ts index 081c8d9f69..60a6a2bba9 100644 --- a/src/problem2/vite.config.ts +++ b/src/problem2/vite.config.ts @@ -1,6 +1,12 @@ +/// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + }, }); From 230587ff425b025734c5fb71b4f7e7b86c526a92 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:35:16 +0700 Subject: [PATCH 07/21] feat(problem2): add shared types and token helper functions --- src/problem2/src/lib/tokens.test.ts | 63 +++++++++++++++++++++++++++++ src/problem2/src/lib/tokens.ts | 61 ++++++++++++++++++++++++++++ src/problem2/src/types.ts | 34 ++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/problem2/src/lib/tokens.test.ts create mode 100644 src/problem2/src/lib/tokens.ts create mode 100644 src/problem2/src/types.ts diff --git a/src/problem2/src/lib/tokens.test.ts b/src/problem2/src/lib/tokens.test.ts new file mode 100644 index 0000000000..1442124087 --- /dev/null +++ b/src/problem2/src/lib/tokens.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeAmount, formatAmount, formatUsd, formatDisplayNumber } from "./tokens"; + +describe("sanitizeAmount", () => { + it("keeps a plain integer", () => { + expect(sanitizeAmount("123")).toBe("123"); + }); + it("keeps one decimal point", () => { + expect(sanitizeAmount("1.5")).toBe("1.5"); + }); + it("strips non-numeric characters", () => { + expect(sanitizeAmount("1a2b3c")).toBe("123"); + }); + it("collapses repeated decimal points", () => { + expect(sanitizeAmount("1.2.3")).toBe("1.23"); + }); + it("caps at 8 decimal places", () => { + expect(sanitizeAmount("0.123456789012")).toBe("0.12345678"); + }); + it("strips leading zeros from integers", () => { + expect(sanitizeAmount("0005")).toBe("5"); + }); + it("preserves 0.5", () => { + expect(sanitizeAmount("0.5")).toBe("0.5"); + }); + it("preserves a lone 0", () => { + expect(sanitizeAmount("0")).toBe("0"); + }); + it("rewrites a leading dot to 0.", () => { + expect(sanitizeAmount(".5")).toBe("0.5"); + }); + it("handles empty input", () => { + expect(sanitizeAmount("")).toBe(""); + }); +}); + +describe("formatAmount", () => { + it("returns '0' for zero", () => { + expect(formatAmount(0)).toBe("0"); + }); + it("returns '0' for NaN", () => { + expect(formatAmount(NaN)).toBe("0"); + }); + it("strips trailing zeros", () => { + expect(formatAmount(1.5)).toBe("1.5"); + expect(formatAmount(3200)).toBe("3200"); + }); + it("respects 8 decimal cap", () => { + expect(formatAmount(0.123456789)).toBe("0.12345679"); + }); +}); + +describe("formatUsd", () => { + it("formats with $ and two decimals", () => { + expect(formatUsd(3200)).toBe("$3,200.00"); + }); +}); + +describe("formatDisplayNumber", () => { + it("groups thousands", () => { + expect(formatDisplayNumber(3200)).toBe("3,200"); + }); +}); diff --git a/src/problem2/src/lib/tokens.ts b/src/problem2/src/lib/tokens.ts new file mode 100644 index 0000000000..befb2ca1c8 --- /dev/null +++ b/src/problem2/src/lib/tokens.ts @@ -0,0 +1,61 @@ +const ICON_BASE = + "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens"; + +export function iconUrlFor(symbol: string): string { + return `${ICON_BASE}/${symbol}.svg`; +} + +/** + * Format a number for display in the amount input. Strips trailing zeros, + * caps at 8 decimal places, no thousands separators (so the value can be + * round-tripped through parseFloat). + */ +export function formatAmount(n: number): string { + if (!Number.isFinite(n) || n === 0) return "0"; + const fixed = n.toFixed(8); + return fixed.replace(/\.?0+$/, "") || "0"; +} + +/** Format a USD value: $3,200.00 */ +export function formatUsd(n: number): string { + if (!Number.isFinite(n)) return "$0.00"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(n); +} + +/** Format a rate / display number with thousands separators, 6 sig figs. */ +export function formatDisplayNumber(n: number): string { + if (!Number.isFinite(n)) return "0"; + return new Intl.NumberFormat("en-US", { + maximumSignificantDigits: 6, + useGrouping: true, + }).format(n); +} + +/** + * Sanitize a raw user-typed amount string: + * - keep only digits and one decimal point + * - cap to 8 decimal places + * - collapse leading zeros ("0005" -> "5", "0.5" preserved) + * - rewrite a leading "." to "0." + */ +export function sanitizeAmount(raw: string): string { + let s = raw.replace(/[^0-9.]/g, ""); + const firstDot = s.indexOf("."); + if (firstDot !== -1) { + s = s.slice(0, firstDot + 1) + s.slice(firstDot + 1).replace(/\./g, ""); + } + const dot = s.indexOf("."); + if (dot !== -1 && s.length - dot - 1 > 8) { + s = s.slice(0, dot + 1 + 8); + } + if (s.length > 1 && s[0] === "0" && s[1] !== ".") { + s = s.replace(/^0+/, ""); + if (s === "" || s.startsWith(".")) s = "0" + s; + } + if (s.startsWith(".")) s = "0" + s; + return s; +} diff --git a/src/problem2/src/types.ts b/src/problem2/src/types.ts new file mode 100644 index 0000000000..3023da6361 --- /dev/null +++ b/src/problem2/src/types.ts @@ -0,0 +1,34 @@ +export type Token = { + symbol: string; // "ETH", "USDC", ... + price: number; // USD per 1 token + iconUrl: string; // remote SVG URL +}; + +export type PriceEntry = { + currency: string; + date: string; // ISO 8601 + price: number; +}; + +export type SwapState = { + fromToken: Token | null; + toToken: Token | null; + fromAmount: string; + toAmount: string; + lastEdited: "from" | "to"; + touched: { from: boolean; to: boolean }; + status: "idle" | "submitting"; +}; + +export type ValidationErrors = { + fromToken?: string; + toToken?: string; + fromAmount?: string; + pair?: string; +}; + +export type ValidationResult = { + errors: ValidationErrors; + isValid: boolean; + firstError: string | null; +}; From a18a64c3d8c0e06abefacc4b43ae44b45b40623e Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:39:13 +0700 Subject: [PATCH 08/21] fix(problem2): sanitizeAmount rejects bad input, formatAmount clamps negatives --- src/problem2/src/lib/tokens.test.ts | 32 +++++++++++++++++++++++++++-- src/problem2/src/lib/tokens.ts | 14 +++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/problem2/src/lib/tokens.test.ts b/src/problem2/src/lib/tokens.test.ts index 1442124087..b6083c0a26 100644 --- a/src/problem2/src/lib/tokens.test.ts +++ b/src/problem2/src/lib/tokens.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sanitizeAmount, formatAmount, formatUsd, formatDisplayNumber } from "./tokens"; +import { sanitizeAmount, formatAmount, formatUsd, formatDisplayNumber, iconUrlFor } from "./tokens"; describe("sanitizeAmount", () => { it("keeps a plain integer", () => { @@ -9,7 +9,7 @@ describe("sanitizeAmount", () => { expect(sanitizeAmount("1.5")).toBe("1.5"); }); it("strips non-numeric characters", () => { - expect(sanitizeAmount("1a2b3c")).toBe("123"); + expect(sanitizeAmount("1a2b3c")).toBe("1"); }); it("collapses repeated decimal points", () => { expect(sanitizeAmount("1.2.3")).toBe("1.23"); @@ -32,6 +32,19 @@ describe("sanitizeAmount", () => { it("handles empty input", () => { expect(sanitizeAmount("")).toBe(""); }); + it("rejects negatives", () => { + expect(sanitizeAmount("-5")).toBe(""); + }); + it("rejects exponential notation by truncating at 'e'", () => { + expect(sanitizeAmount("1e5")).toBe("1"); + }); + it("strips thousands separators", () => { + expect(sanitizeAmount("1,500")).toBe("1500"); + expect(sanitizeAmount("1,234.56")).toBe("1234.56"); + }); + it("truncates at first invalid character", () => { + expect(sanitizeAmount("12abc34")).toBe("12"); + }); }); describe("formatAmount", () => { @@ -48,6 +61,10 @@ describe("formatAmount", () => { it("respects 8 decimal cap", () => { expect(formatAmount(0.123456789)).toBe("0.12345679"); }); + it("returns '0' for negative inputs", () => { + expect(formatAmount(-1)).toBe("0"); + expect(formatAmount(-0.00000001)).toBe("0"); + }); }); describe("formatUsd", () => { @@ -61,3 +78,14 @@ describe("formatDisplayNumber", () => { expect(formatDisplayNumber(3200)).toBe("3,200"); }); }); + +describe("iconUrlFor", () => { + it("returns the Switcheo token-icons GitHub raw URL", () => { + expect(iconUrlFor("ETH")).toBe( + "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens/ETH.svg", + ); + }); + it("uses the symbol verbatim (case-sensitive)", () => { + expect(iconUrlFor("bNEO")).toContain("/bNEO.svg"); + }); +}); diff --git a/src/problem2/src/lib/tokens.ts b/src/problem2/src/lib/tokens.ts index befb2ca1c8..f175ba1a91 100644 --- a/src/problem2/src/lib/tokens.ts +++ b/src/problem2/src/lib/tokens.ts @@ -11,7 +11,7 @@ export function iconUrlFor(symbol: string): string { * round-tripped through parseFloat). */ export function formatAmount(n: number): string { - if (!Number.isFinite(n) || n === 0) return "0"; + if (!Number.isFinite(n) || n <= 0) return "0"; const fixed = n.toFixed(8); return fixed.replace(/\.?0+$/, "") || "0"; } @@ -43,19 +43,29 @@ export function formatDisplayNumber(n: number): string { * - rewrite a leading "." to "0." */ export function sanitizeAmount(raw: string): string { - let s = raw.replace(/[^0-9.]/g, ""); + // Strip thousands separators (commas) — common when pasting from Excel/etc. + const stripped = raw.replace(/,/g, ""); + // Take only the leading run of digits and dots; truncate at first other char. + // This rejects negatives ("-5"), exponentials ("1e5"), and other surprises + // rather than silently mutating them. + const match = stripped.match(/^[0-9.]*/); + let s = match ? match[0] : ""; + // Keep only the first decimal point const firstDot = s.indexOf("."); if (firstDot !== -1) { s = s.slice(0, firstDot + 1) + s.slice(firstDot + 1).replace(/\./g, ""); } + // Cap to 8 decimal places const dot = s.indexOf("."); if (dot !== -1 && s.length - dot - 1 > 8) { s = s.slice(0, dot + 1 + 8); } + // Collapse leading zeros: "0005" -> "5", but keep "0.5" and "0" if (s.length > 1 && s[0] === "0" && s[1] !== ".") { s = s.replace(/^0+/, ""); if (s === "" || s.startsWith(".")) s = "0" + s; } + // Rewrite leading "." to "0." if (s.startsWith(".")) s = "0" + s; return s; } From 389b21c2536c4a454370f85e1fe050e395d6b4b2 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:40:35 +0700 Subject: [PATCH 09/21] feat(problem2): add convert() with USD-bridged exchange math --- src/problem2/src/lib/swap.test.ts | 42 +++++++++++++++++++++++++++++++ src/problem2/src/lib/swap.ts | 22 ++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/problem2/src/lib/swap.test.ts create mode 100644 src/problem2/src/lib/swap.ts diff --git a/src/problem2/src/lib/swap.test.ts b/src/problem2/src/lib/swap.test.ts new file mode 100644 index 0000000000..16e7251562 --- /dev/null +++ b/src/problem2/src/lib/swap.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { convert } from "./swap"; + +describe("convert", () => { + it("returns 0 for zero amountIn", () => { + expect(convert(0, 1, 1)).toBe(0); + }); + + it("returns the same amount when prices are identical", () => { + expect(convert(1.5, 100, 100)).toBe(1.5); + }); + + it("converts 1 ETH at $3200 to 3200 USDC at $1", () => { + expect(convert(1, 3200, 1)).toBe(3200); + }); + + it("converts 3200 USDC at $1 to 1 ETH at $3200", () => { + expect(convert(3200, 1, 3200)).toBe(1); + }); + + it("handles fractional prices", () => { + expect(convert(100, 0.2, 2)).toBe(10); + }); + + it("returns 0 when toPrice is 0 or invalid (guard)", () => { + expect(convert(10, 1, 0)).toBe(0); + expect(convert(10, 1, NaN)).toBe(0); + }); + + it("returns 0 when amountIn or fromPrice is invalid", () => { + expect(convert(NaN, 1, 1)).toBe(0); + expect(convert(10, NaN, 1)).toBe(0); + }); + + it("handles very large values without overflow", () => { + expect(convert(1e10, 3200, 1)).toBe(3.2e13); + }); + + it("handles very small values", () => { + expect(convert(1e-6, 3200, 1)).toBeCloseTo(3.2e-3, 10); + }); +}); diff --git a/src/problem2/src/lib/swap.ts b/src/problem2/src/lib/swap.ts new file mode 100644 index 0000000000..1c0806e4bc --- /dev/null +++ b/src/problem2/src/lib/swap.ts @@ -0,0 +1,22 @@ +/** + * Convert an amount of one token to its USD-equivalent amount of another token. + * + * Both prices are USD per 1 token. The rate is fromPrice / toPrice. + * Returns 0 for any non-finite or zero-denominator input — callers display "0" + * in the receive field in that case, which is the right user-facing behavior. + */ +export function convert( + amountIn: number, + fromPrice: number, + toPrice: number, +): number { + if ( + !Number.isFinite(amountIn) || + !Number.isFinite(fromPrice) || + !Number.isFinite(toPrice) || + toPrice === 0 + ) { + return 0; + } + return amountIn * (fromPrice / toPrice); +} From 9359820c067f65e4d3e3809d533ff8323b4d20d2 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:42:14 +0700 Subject: [PATCH 10/21] feat(problem2): add validate() for swap form state --- src/problem2/src/lib/validation.test.ts | 87 +++++++++++++++++++++++++ src/problem2/src/lib/validation.ts | 52 +++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/problem2/src/lib/validation.test.ts create mode 100644 src/problem2/src/lib/validation.ts diff --git a/src/problem2/src/lib/validation.test.ts b/src/problem2/src/lib/validation.test.ts new file mode 100644 index 0000000000..a954021cc7 --- /dev/null +++ b/src/problem2/src/lib/validation.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { validate } from "./validation"; +import type { SwapState, Token } from "../types"; + +const ETH: Token = { symbol: "ETH", price: 3200, iconUrl: "" }; +const USDC: Token = { symbol: "USDC", price: 1, iconUrl: "" }; + +function state(overrides: Partial = {}): SwapState { + return { + fromToken: ETH, + toToken: USDC, + fromAmount: "1", + toAmount: "3200", + lastEdited: "from", + touched: { from: true, to: true }, + status: "idle", + ...overrides, + }; +} + +describe("validate", () => { + it("returns valid for a well-formed state", () => { + const r = validate(state()); + expect(r.isValid).toBe(true); + expect(r.errors).toEqual({}); + expect(r.firstError).toBeNull(); + }); + + it("flags missing fromToken", () => { + const r = validate(state({ fromToken: null })); + expect(r.isValid).toBe(false); + expect(r.errors.fromToken).toMatch(/select a token/i); + }); + + it("flags missing toToken", () => { + const r = validate(state({ toToken: null })); + expect(r.isValid).toBe(false); + expect(r.errors.toToken).toMatch(/select a token/i); + }); + + it("flags identical tokens", () => { + const r = validate(state({ toToken: ETH })); + expect(r.isValid).toBe(false); + expect(r.errors.pair).toMatch(/cannot swap a token for itself/i); + }); + + it("flags empty amount", () => { + const r = validate(state({ fromAmount: "" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + }); + + it("flags zero amount", () => { + const r = validate(state({ fromAmount: "0" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + }); + + it("flags non-numeric amount", () => { + const r = validate(state({ fromAmount: "abc" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + }); + + it("flags absurdly large amount", () => { + const r = validate(state({ fromAmount: "1e16" })); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/too large/i); + }); + + it("firstError returns the first non-empty error", () => { + const r = validate(state({ fromToken: null, fromAmount: "" })); + expect(r.firstError).toMatch(/select a token/i); + }); + + it("validates the To side when lastEdited is 'to'", () => { + const r = validate( + state({ + fromAmount: "0", + toAmount: "0", + lastEdited: "to", + }), + ); + expect(r.isValid).toBe(false); + expect(r.errors.fromAmount).toMatch(/greater than 0/i); + }); +}); diff --git a/src/problem2/src/lib/validation.ts b/src/problem2/src/lib/validation.ts new file mode 100644 index 0000000000..8229095d91 --- /dev/null +++ b/src/problem2/src/lib/validation.ts @@ -0,0 +1,52 @@ +import type { SwapState, ValidationResult, ValidationErrors } from "../types"; + +const MAX_AMOUNT = 1e15; + +export function validate(state: SwapState): ValidationResult { + const errors: ValidationErrors = {}; + + if (!state.fromToken) { + errors.fromToken = "Select a token to swap from"; + } + if (!state.toToken) { + errors.toToken = "Select a token to receive"; + } + if ( + state.fromToken && + state.toToken && + state.fromToken.symbol === state.toToken.symbol + ) { + errors.pair = "Cannot swap a token for itself"; + } + + // The "driving" amount is whichever side the user last typed in + const driverRaw = + state.lastEdited === "from" ? state.fromAmount : state.toAmount; + const driverNum = parseFloat(driverRaw); + + if ( + driverRaw.trim() === "" || + !Number.isFinite(driverNum) || + driverNum <= 0 + ) { + errors.fromAmount = "Enter an amount greater than 0"; + } else if (driverNum > MAX_AMOUNT) { + errors.fromAmount = "Amount is too large"; + } + + // firstError follows display priority: pair > tokens > amount + const order: (keyof ValidationErrors)[] = [ + "pair", + "fromToken", + "toToken", + "fromAmount", + ]; + const firstKey = order.find((k) => errors[k]); + const firstError = firstKey ? errors[firstKey]! : null; + + return { + errors, + isValid: Object.keys(errors).length === 0, + firstError, + }; +} From 12ceaec3ac6fa708efed44fad02181ad4a854dc5 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:44:18 +0700 Subject: [PATCH 11/21] feat(problem2): add usePrices hook with dedupe and retry --- src/problem2/src/hooks/usePrices.test.ts | 97 ++++++++++++++++++++++++ src/problem2/src/hooks/usePrices.ts | 69 +++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/problem2/src/hooks/usePrices.test.ts create mode 100644 src/problem2/src/hooks/usePrices.ts diff --git a/src/problem2/src/hooks/usePrices.test.ts b/src/problem2/src/hooks/usePrices.test.ts new file mode 100644 index 0000000000..da31e925a0 --- /dev/null +++ b/src/problem2/src/hooks/usePrices.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { usePrices } from "./usePrices"; + +const SAMPLE = [ + { currency: "BUSD", date: "2023-08-29T07:10:40.000Z", price: 0.999 }, + { currency: "BUSD", date: "2023-08-29T07:11:40.000Z", price: 1.001 }, + { currency: "ETH", date: "2023-08-29T07:10:52.000Z", price: 1645.93 }, + { currency: "USDC", date: "2023-08-29T07:10:40.000Z", price: 1 }, + { currency: "BROKEN", date: "2023-08-29T07:10:40.000Z", price: 0 }, + { currency: "NULLY", date: "2023-08-29T07:10:40.000Z", price: null }, +]; + +describe("usePrices", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(SAMPLE), + } as Response), + ), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("starts in loading state", () => { + const { result } = renderHook(() => usePrices()); + expect(result.current.isLoading).toBe(true); + expect(result.current.tokens).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it("returns deduped tokens sorted by symbol after fetch resolves", async () => { + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const symbols = result.current.tokens.map((t) => t.symbol); + expect(symbols).toEqual(["BUSD", "ETH", "USDC"]); + + const busd = result.current.tokens.find((t) => t.symbol === "BUSD")!; + expect(busd.price).toBe(1.001); + }); + + it("attaches an icon URL to each token", async () => { + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + const eth = result.current.tokens.find((t) => t.symbol === "ETH")!; + expect(eth.iconUrl).toBe( + "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens/ETH.svg", + ); + }); + + it("filters out tokens with falsy or non-finite prices", async () => { + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + const symbols = result.current.tokens.map((t) => t.symbol); + expect(symbols).not.toContain("BROKEN"); + expect(symbols).not.toContain("NULLY"); + }); + + it("surfaces a network error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.tokens).toEqual([]); + }); + + it("retry() re-runs the fetch", async () => { + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.reject(new Error("offline")); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(SAMPLE), + } as Response); + }), + ); + const { result } = renderHook(() => usePrices()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBeInstanceOf(Error); + + result.current.retry(); + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.tokens.length).toBeGreaterThan(0); + }); +}); diff --git a/src/problem2/src/hooks/usePrices.ts b/src/problem2/src/hooks/usePrices.ts new file mode 100644 index 0000000000..720c04d647 --- /dev/null +++ b/src/problem2/src/hooks/usePrices.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from "react"; +import type { PriceEntry, Token } from "../types"; +import { iconUrlFor } from "../lib/tokens"; + +const PRICES_URL = "https://interview.switcheo.com/prices.json"; + +type UsePricesResult = { + tokens: Token[]; + isLoading: boolean; + error: Error | null; + retry: () => void; +}; + +function dedupe(entries: PriceEntry[]): Token[] { + const latest = new Map(); + for (const e of entries) { + if (!e || !e.currency) continue; + if (typeof e.price !== "number" || !Number.isFinite(e.price) || e.price <= 0) continue; + const existing = latest.get(e.currency); + if (!existing || new Date(e.date).getTime() > new Date(existing.date).getTime()) { + latest.set(e.currency, e); + } + } + return Array.from(latest.values()) + .sort((a, b) => a.currency.localeCompare(b.currency)) + .map((e) => ({ + symbol: e.currency, + price: e.price, + iconUrl: iconUrlFor(e.currency), + })); +} + +export function usePrices(): UsePricesResult { + const [tokens, setTokens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(null); + + fetch(PRICES_URL) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data: PriceEntry[]) => { + if (cancelled) return; + setTokens(dedupe(data)); + setIsLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err : new Error(String(err))); + setTokens([]); + setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [attempt]); + + const retry = useCallback(() => setAttempt((n) => n + 1), []); + + return { tokens, isLoading, error, retry }; +} From 606d25750d65e05db8f491b18c1285a7a7d7456b Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:46:55 +0700 Subject: [PATCH 12/21] feat(problem2): add useSwapReducer with two-way binding --- src/problem2/src/hooks/useSwapReducer.test.ts | 124 ++++++++++++++++++ src/problem2/src/hooks/useSwapReducer.ts | 124 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 src/problem2/src/hooks/useSwapReducer.test.ts create mode 100644 src/problem2/src/hooks/useSwapReducer.ts diff --git a/src/problem2/src/hooks/useSwapReducer.test.ts b/src/problem2/src/hooks/useSwapReducer.test.ts new file mode 100644 index 0000000000..9d33d1261c --- /dev/null +++ b/src/problem2/src/hooks/useSwapReducer.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { swapReducer, initialSwapState } from "./useSwapReducer"; +import type { Token } from "../types"; + +const ETH: Token = { symbol: "ETH", price: 3200, iconUrl: "" }; +const USDC: Token = { symbol: "USDC", price: 1, iconUrl: "" }; +const BTC: Token = { symbol: "BTC", price: 60000, iconUrl: "" }; + +const start = initialSwapState(ETH, USDC); + +describe("swapReducer", () => { + describe("initialSwapState", () => { + it("pre-fills tokens when both provided", () => { + expect(start.fromToken).toBe(ETH); + expect(start.toToken).toBe(USDC); + expect(start.fromAmount).toBe(""); + expect(start.toAmount).toBe(""); + expect(start.lastEdited).toBe("from"); + expect(start.status).toBe("idle"); + expect(start.touched).toEqual({ from: false, to: false }); + }); + + it("handles missing default tokens", () => { + const s = initialSwapState(null, null); + expect(s.fromToken).toBeNull(); + expect(s.toToken).toBeNull(); + }); + }); + + describe("SET_FROM_AMOUNT", () => { + it("sets fromAmount and recomputes toAmount", () => { + const s = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + expect(s.fromAmount).toBe("1"); + expect(s.toAmount).toBe("3200"); + expect(s.lastEdited).toBe("from"); + }); + + it("clears toAmount when fromAmount is empty", () => { + const s = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "" }); + expect(s.fromAmount).toBe(""); + expect(s.toAmount).toBe(""); + }); + }); + + describe("SET_TO_AMOUNT", () => { + it("sets toAmount and recomputes fromAmount, flips lastEdited", () => { + const s = swapReducer(start, { type: "SET_TO_AMOUNT", value: "3200" }); + expect(s.toAmount).toBe("3200"); + expect(s.fromAmount).toBe("1"); + expect(s.lastEdited).toBe("to"); + }); + }); + + describe("SET_FROM_TOKEN", () => { + it("changes fromToken and re-derives the non-driven side", () => { + const seeded = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + const s = swapReducer(seeded, { type: "SET_FROM_TOKEN", token: BTC }); + expect(s.fromToken).toBe(BTC); + expect(s.fromAmount).toBe("1"); + expect(s.toAmount).toBe("60000"); + }); + + it("when lastEdited='to', re-derives fromAmount instead", () => { + const seeded = swapReducer(start, { type: "SET_TO_AMOUNT", value: "60000" }); + const s = swapReducer(seeded, { type: "SET_FROM_TOKEN", token: BTC }); + expect(s.toAmount).toBe("60000"); + expect(s.fromAmount).toBe("1"); + }); + }); + + describe("FLIP", () => { + it("swaps tokens, amounts, and lastEdited", () => { + const seeded = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + const s = swapReducer(seeded, { type: "FLIP" }); + expect(s.fromToken).toBe(USDC); + expect(s.toToken).toBe(ETH); + expect(s.fromAmount).toBe("3200"); + expect(s.toAmount).toBe("1"); + expect(s.lastEdited).toBe("to"); + }); + }); + + describe("BLUR_FROM / BLUR_TO", () => { + it("marks touched.from on BLUR_FROM", () => { + const s = swapReducer(start, { type: "BLUR_FROM" }); + expect(s.touched.from).toBe(true); + expect(s.touched.to).toBe(false); + }); + it("marks touched.to on BLUR_TO", () => { + const s = swapReducer(start, { type: "BLUR_TO" }); + expect(s.touched.to).toBe(true); + }); + }); + + describe("SUBMIT_START / RESET", () => { + it("SUBMIT_START sets status to submitting", () => { + const s = swapReducer(start, { type: "SUBMIT_START" }); + expect(s.status).toBe("submitting"); + }); + + it("RESET clears amounts but keeps token selections", () => { + const seeded = swapReducer(start, { type: "SET_FROM_AMOUNT", value: "1" }); + const submitted = swapReducer(seeded, { type: "SUBMIT_START" }); + const s = swapReducer(submitted, { type: "RESET" }); + expect(s.fromAmount).toBe(""); + expect(s.toAmount).toBe(""); + expect(s.fromToken).toBe(ETH); + expect(s.toToken).toBe(USDC); + expect(s.status).toBe("idle"); + expect(s.touched).toEqual({ from: false, to: false }); + }); + }); + + describe("SET_TO_TOKEN when one token is null", () => { + it("derives nothing if other side is missing", () => { + const empty = initialSwapState(null, null); + const s1 = swapReducer(empty, { type: "SET_FROM_AMOUNT", value: "1" }); + expect(s1.fromAmount).toBe("1"); + expect(s1.toAmount).toBe(""); + const s2 = swapReducer(s1, { type: "SET_FROM_TOKEN", token: ETH }); + expect(s2.toAmount).toBe(""); + }); + }); +}); diff --git a/src/problem2/src/hooks/useSwapReducer.ts b/src/problem2/src/hooks/useSwapReducer.ts new file mode 100644 index 0000000000..f15876e56b --- /dev/null +++ b/src/problem2/src/hooks/useSwapReducer.ts @@ -0,0 +1,124 @@ +import { useReducer } from "react"; +import type { SwapState, Token } from "../types"; +import { convert } from "../lib/swap"; +import { formatAmount, sanitizeAmount } from "../lib/tokens"; + +export type SwapAction = + | { type: "SET_FROM_TOKEN"; token: Token } + | { type: "SET_TO_TOKEN"; token: Token } + | { type: "SET_FROM_AMOUNT"; value: string } + | { type: "SET_TO_AMOUNT"; value: string } + | { type: "BLUR_FROM" } + | { type: "BLUR_TO" } + | { type: "FLIP" } + | { type: "SUBMIT_START" } + | { type: "RESET" }; + +export function initialSwapState( + fromToken: Token | null, + toToken: Token | null, +): SwapState { + return { + fromToken, + toToken, + fromAmount: "", + toAmount: "", + lastEdited: "from", + touched: { from: false, to: false }, + status: "idle", + }; +} + +function rederive(s: SwapState): { fromAmount: string; toAmount: string } { + const { fromToken, toToken, fromAmount, toAmount, lastEdited } = s; + + if (lastEdited === "from") { + if (!fromToken || !toToken || fromAmount === "") { + return { fromAmount, toAmount: "" }; + } + const n = parseFloat(fromAmount); + if (!Number.isFinite(n) || n === 0) { + return { fromAmount, toAmount: "0" }; + } + const out = convert(n, fromToken.price, toToken.price); + return { fromAmount, toAmount: formatAmount(out) }; + } else { + if (!fromToken || !toToken || toAmount === "") { + return { fromAmount: "", toAmount }; + } + const n = parseFloat(toAmount); + if (!Number.isFinite(n) || n === 0) { + return { fromAmount: "0", toAmount }; + } + const out = convert(n, toToken.price, fromToken.price); + return { fromAmount: formatAmount(out), toAmount }; + } +} + +export function swapReducer(state: SwapState, action: SwapAction): SwapState { + switch (action.type) { + case "SET_FROM_AMOUNT": { + const value = sanitizeAmount(action.value); + const next: SwapState = { + ...state, + fromAmount: value, + lastEdited: "from", + }; + const { toAmount } = rederive(next); + return { ...next, toAmount }; + } + case "SET_TO_AMOUNT": { + const value = sanitizeAmount(action.value); + const next: SwapState = { + ...state, + toAmount: value, + lastEdited: "to", + }; + const { fromAmount } = rederive(next); + return { ...next, fromAmount }; + } + case "SET_FROM_TOKEN": { + const next: SwapState = { ...state, fromToken: action.token }; + const { fromAmount, toAmount } = rederive(next); + return { ...next, fromAmount, toAmount }; + } + case "SET_TO_TOKEN": { + const next: SwapState = { ...state, toToken: action.token }; + const { fromAmount, toAmount } = rederive(next); + return { ...next, fromAmount, toAmount }; + } + case "FLIP": { + return { + ...state, + fromToken: state.toToken, + toToken: state.fromToken, + fromAmount: state.toAmount, + toAmount: state.fromAmount, + lastEdited: state.lastEdited === "from" ? "to" : "from", + }; + } + case "BLUR_FROM": + return { ...state, touched: { ...state.touched, from: true } }; + case "BLUR_TO": + return { ...state, touched: { ...state.touched, to: true } }; + case "SUBMIT_START": + return { ...state, status: "submitting" }; + case "RESET": + return { + ...state, + fromAmount: "", + toAmount: "", + touched: { from: false, to: false }, + status: "idle", + }; + default: + return state; + } +} + +export function useSwapReducer( + fromToken: Token | null, + toToken: Token | null, +) { + return useReducer(swapReducer, initialSwapState(fromToken, toToken)); +} From 9ea1f5877e4f006ce5ba9939b8104cf685dba1af Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:48:08 +0700 Subject: [PATCH 13/21] feat(problem2): add TokenIcon and FlipButton components --- src/problem2/src/components/FlipButton.tsx | 42 ++++++++++++++++++++++ src/problem2/src/components/TokenIcon.tsx | 36 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/problem2/src/components/FlipButton.tsx create mode 100644 src/problem2/src/components/TokenIcon.tsx diff --git a/src/problem2/src/components/FlipButton.tsx b/src/problem2/src/components/FlipButton.tsx new file mode 100644 index 0000000000..d124b5cd65 --- /dev/null +++ b/src/problem2/src/components/FlipButton.tsx @@ -0,0 +1,42 @@ +import { motion } from "framer-motion"; + +type Props = { + onClick: () => void; + disabled?: boolean; +}; + +export function FlipButton({ onClick, disabled }: Props) { + return ( + + + + + + + ); +} diff --git a/src/problem2/src/components/TokenIcon.tsx b/src/problem2/src/components/TokenIcon.tsx new file mode 100644 index 0000000000..906f7cf840 --- /dev/null +++ b/src/problem2/src/components/TokenIcon.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; + +type Props = { + symbol: string; + src: string; + size?: number; + className?: string; +}; + +export function TokenIcon({ symbol, src, size = 28, className = "" }: Props) { + const [failed, setFailed] = useState(false); + const initial = symbol.slice(0, 1).toUpperCase(); + + if (failed) { + return ( + + {initial} + + ); + } + return ( + {symbol} setFailed(true)} + loading="lazy" + /> + ); +} From dc6f12587ae7fcf6bd778805ebec824fce8b4842 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:49:27 +0700 Subject: [PATCH 14/21] feat(problem2): add TokenPicker dialog with searchable combobox --- src/problem2/src/components/TokenPicker.tsx | 147 ++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/problem2/src/components/TokenPicker.tsx diff --git a/src/problem2/src/components/TokenPicker.tsx b/src/problem2/src/components/TokenPicker.tsx new file mode 100644 index 0000000000..27d8627a8c --- /dev/null +++ b/src/problem2/src/components/TokenPicker.tsx @@ -0,0 +1,147 @@ +import { Fragment, useState } from "react"; +import { + Combobox, + ComboboxInput, + ComboboxOption, + ComboboxOptions, + Dialog, + DialogPanel, + Transition, + TransitionChild, +} from "@headlessui/react"; +import type { Token } from "../types"; +import { TokenIcon } from "./TokenIcon"; +import { formatUsd } from "../lib/tokens"; + +type Props = { + isOpen: boolean; + onClose: () => void; + tokens: Token[]; + selected: Token | null; + exclude?: Token | null; + onSelect: (token: Token) => void; +}; + +export function TokenPicker({ + isOpen, + onClose, + tokens, + selected, + exclude, + onSelect, +}: Props) { + const [query, setQuery] = useState(""); + + const filtered = tokens + .filter((t) => !exclude || t.symbol !== exclude.symbol) + .filter((t) => t.symbol.toLowerCase().includes(query.toLowerCase())); + + function handleClose() { + setQuery(""); + onClose(); + } + + function handleSelect(t: Token | null) { + if (!t) return; + onSelect(t); + handleClose(); + } + + return ( + + + +
+ + +
+ + + +
+

Select a token

+ setQuery(e.target.value)} + displayValue={() => query} + className=" + focus-ring w-full rounded-2xl border border-border-card + bg-surface-input px-4 py-3 text-ink-primary + placeholder:text-ink-muted + " + /> +
+ + {filtered.length === 0 && ( +
+ No tokens match "{query}" +
+ )} + {filtered.map((token) => ( + + {({ focus, selected: isSelected }) => ( +
+
+ +
+
+ {token.symbol} +
+
+ {formatUsd(token.price)} +
+
+
+ {isSelected && ( + + )} +
+ )} +
+ ))} +
+
+
+
+
+
+
+ ); +} From 2fa7fc51ae106411089b4dcb1f3480234f4b6a6f Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:50:28 +0700 Subject: [PATCH 15/21] feat(problem2): add TokenAmountField with token picker integration --- .../src/components/TokenAmountField.tsx | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/problem2/src/components/TokenAmountField.tsx diff --git a/src/problem2/src/components/TokenAmountField.tsx b/src/problem2/src/components/TokenAmountField.tsx new file mode 100644 index 0000000000..8cc2f4e4ee --- /dev/null +++ b/src/problem2/src/components/TokenAmountField.tsx @@ -0,0 +1,131 @@ +import { forwardRef, useId, useState } from "react"; +import type { Token } from "../types"; +import { formatUsd, sanitizeAmount } from "../lib/tokens"; +import { TokenIcon } from "./TokenIcon"; +import { TokenPicker } from "./TokenPicker"; + +type Props = { + label: string; + amount: string; + token: Token | null; + tokens: Token[]; + otherToken: Token | null; + onAmountChange: (value: string) => void; + onTokenChange: (token: Token) => void; + onBlur?: () => void; + error?: string; + disabled?: boolean; + placeholder?: string; +}; + +export const TokenAmountField = forwardRef( + function TokenAmountField( + { + label, + amount, + token, + tokens, + otherToken, + onAmountChange, + onTokenChange, + onBlur, + error, + disabled, + placeholder = "0.0", + }, + ref, + ) { + const [pickerOpen, setPickerOpen] = useState(false); + const inputId = useId(); + const errorId = useId(); + + const numericAmount = parseFloat(amount); + const usdValue = + token && Number.isFinite(numericAmount) ? numericAmount * token.price : 0; + + return ( +
+ + +
+ onAmountChange(sanitizeAmount(e.target.value))} + onBlur={onBlur} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} + className=" + focus-ring min-w-0 flex-1 bg-transparent text-2xl font-semibold + text-ink-primary tabular-nums placeholder:text-ink-muted/60 + outline-none + " + /> + + +
+ +
+ + {token && usdValue > 0 ? `≈ ${formatUsd(usdValue)}` : " "} + + {error && ( + + {error} + + )} +
+ + setPickerOpen(false)} + tokens={tokens} + selected={token} + exclude={otherToken} + onSelect={onTokenChange} + /> +
+ ); + }, +); From 587ed8bc974d82e9c6a1038126aeeb6332c51e59 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:51:53 +0700 Subject: [PATCH 16/21] feat(problem2): add RateSummary and SubmitButton components --- src/problem2/src/components/RateSummary.tsx | 28 +++++++++++++++ src/problem2/src/components/SubmitButton.tsx | 38 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/problem2/src/components/RateSummary.tsx create mode 100644 src/problem2/src/components/SubmitButton.tsx diff --git a/src/problem2/src/components/RateSummary.tsx b/src/problem2/src/components/RateSummary.tsx new file mode 100644 index 0000000000..ebc653b8c3 --- /dev/null +++ b/src/problem2/src/components/RateSummary.tsx @@ -0,0 +1,28 @@ +import type { Token } from "../types"; +import { formatDisplayNumber } from "../lib/tokens"; + +type Props = { + fromToken: Token | null; + toToken: Token | null; +}; + +export function RateSummary({ fromToken, toToken }: Props) { + if (!fromToken || !toToken || fromToken.symbol === toToken.symbol) { + return null; + } + const rate = fromToken.price / toToken.price; + return ( +
+ + + + + + Updated just now + + + 1 {fromToken.symbol} ≈ {formatDisplayNumber(rate)} {toToken.symbol} + +
+ ); +} diff --git a/src/problem2/src/components/SubmitButton.tsx b/src/problem2/src/components/SubmitButton.tsx new file mode 100644 index 0000000000..e46d94d6b9 --- /dev/null +++ b/src/problem2/src/components/SubmitButton.tsx @@ -0,0 +1,38 @@ +import { motion } from "framer-motion"; + +type Props = { + isSubmitting: boolean; + disabled: boolean; + disabledReason?: string | null; +}; + +export function SubmitButton({ isSubmitting, disabled, disabledReason }: Props) { + const label = isSubmitting ? "Swapping…" : "Confirm Swap"; + return ( + + + {isSubmitting && ( + + + + + )} + {label} + + + ); +} From f957c9a28431734dc9f813553174909584410e1f Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:53:14 +0700 Subject: [PATCH 17/21] feat(problem2): assemble SwapForm with submit flow --- src/problem2/src/components/SwapForm.tsx | 154 +++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/problem2/src/components/SwapForm.tsx diff --git a/src/problem2/src/components/SwapForm.tsx b/src/problem2/src/components/SwapForm.tsx new file mode 100644 index 0000000000..1cf670d899 --- /dev/null +++ b/src/problem2/src/components/SwapForm.tsx @@ -0,0 +1,154 @@ +import { useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { Token } from "../types"; +import { useSwapReducer } from "../hooks/useSwapReducer"; +import { validate } from "../lib/validation"; +import { formatDisplayNumber } from "../lib/tokens"; +import { TokenAmountField } from "./TokenAmountField"; +import { FlipButton } from "./FlipButton"; +import { RateSummary } from "./RateSummary"; +import { SubmitButton } from "./SubmitButton"; + +type Props = { + tokens: Token[]; +}; + +const PREFERRED_FROM = "ETH"; +const PREFERRED_TO = "USDC"; + +function pickDefaults(tokens: Token[]): { + from: Token | null; + to: Token | null; +} { + if (tokens.length === 0) return { from: null, to: null }; + const from = + tokens.find((t) => t.symbol === PREFERRED_FROM) ?? tokens[0] ?? null; + const to = + tokens.find((t) => t.symbol === PREFERRED_TO && t.symbol !== from?.symbol) ?? + tokens.find((t) => t.symbol !== from?.symbol) ?? + null; + return { from, to }; +} + +export function SwapForm({ tokens }: Props) { + const defaults = useMemo(() => pickDefaults(tokens), [tokens]); + const [state, dispatch] = useSwapReducer(defaults.from, defaults.to); + const [submitAttempted, setSubmitAttempted] = useState(false); + const fromInputRef = useRef(null); + + const validation = useMemo(() => validate(state), [state]); + + const showFromAmountError = + (state.touched.from || state.touched.to || submitAttempted) && + !!validation.errors.fromAmount; + const showTokenErrors = submitAttempted; + const showPairError = !!validation.errors.pair; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitAttempted(true); + if (!validation.isValid || state.status === "submitting") return; + + const summary = { + fromAmount: formatDisplayNumber(parseFloat(state.fromAmount)), + fromSymbol: state.fromToken!.symbol, + toAmount: formatDisplayNumber(parseFloat(state.toAmount)), + toSymbol: state.toToken!.symbol, + }; + + dispatch({ type: "SUBMIT_START" }); + await new Promise((r) => setTimeout(r, 1500)); + toast.success("Swap complete", { + description: `Swapped ${summary.fromAmount} ${summary.fromSymbol} → ${summary.toAmount} ${summary.toSymbol}`, + }); + dispatch({ type: "RESET" }); + setSubmitAttempted(false); + fromInputRef.current?.focus(); + } + + const isSubmitting = state.status === "submitting"; + const submitDisabled = !validation.isValid || isSubmitting; + + return ( +
+
+

Swap

+
+ +
+ dispatch({ type: "SET_FROM_AMOUNT", value: v })} + onTokenChange={(t) => dispatch({ type: "SET_FROM_TOKEN", token: t })} + onBlur={() => dispatch({ type: "BLUR_FROM" })} + error={ + state.lastEdited === "from" && showFromAmountError + ? validation.errors.fromAmount + : showTokenErrors + ? validation.errors.fromToken + : undefined + } + disabled={isSubmitting} + /> + +
+
+ dispatch({ type: "FLIP" })} + disabled={isSubmitting} + /> +
+
+ + dispatch({ type: "SET_TO_AMOUNT", value: v })} + onTokenChange={(t) => dispatch({ type: "SET_TO_TOKEN", token: t })} + onBlur={() => dispatch({ type: "BLUR_TO" })} + error={ + state.lastEdited === "to" && showFromAmountError + ? validation.errors.fromAmount + : showTokenErrors + ? validation.errors.toToken + : undefined + } + disabled={isSubmitting} + /> +
+ + {showPairError && ( +
+ {validation.errors.pair} +
+ )} + +
+ +
+ +
+ +
+
+ ); +} From 5d68cf29481862c3284a5f499b3bdc7f01a5ea51 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 14:55:05 +0700 Subject: [PATCH 18/21] feat(problem2): wire up App shell, toast, error and loading states + README --- src/problem2/README.md | 50 ++++++++++++++++++++++++++++++ src/problem2/src/App.tsx | 64 ++++++++++++++++++++++++++++++++++++--- src/problem2/src/main.tsx | 12 ++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 src/problem2/README.md diff --git a/src/problem2/README.md b/src/problem2/README.md new file mode 100644 index 0000000000..0016d94f50 --- /dev/null +++ b/src/problem2/README.md @@ -0,0 +1,50 @@ +# Fancy Swap (Problem 2) + +A polished currency swap form built with **Vite + React + TypeScript + Tailwind**. + +## Run it + +```bash +npm install +npm run dev +``` + +Then open the URL Vite prints (usually http://localhost:5173). + +## Other scripts + +```bash +npm run build # type-check + production build +npm run preview # serve the production build locally +npm test # run the unit tests once +npm run test:watch # vitest in watch mode +``` + +## Tech + +- **Vite 5** — build & dev server +- **React 18 + TypeScript** — UI +- **Tailwind CSS** — styling +- **@headlessui/react** — accessible combobox + dialog primitives for the token picker +- **framer-motion** — flip button + button micro-interactions +- **sonner** — success toast +- **vitest** + **@testing-library/react** + **jsdom** — unit tests + +## Manual smoke test + +1. Wait for prices to load (1–2 s; you'll see a skeleton card) +2. ETH → USDC are pre-selected. Type `1` in "You pay" → "You receive" auto-fills with the USDC equivalent +3. Click the round flip button between the two fields → values and tokens swap +4. Tap a token pill → searchable picker opens (try typing "USD") +5. Try to swap the same token both sides → see the inline pair error +6. Try a zero / empty amount → button is muted, hover tooltip explains why +7. Click **Confirm Swap** → spinner ~1.5 s → green toast appears at bottom; amounts reset, tokens kept + +## Data sources + +- Prices: `https://interview.switcheo.com/prices.json` (fetched once, deduped to the most recent entry per symbol; tokens without prices are dropped) +- Token icons: `https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens/.svg` (falls back to a gradient initial chip on load failure) + +## Spec & plan + +See `../../docs/superpowers/specs/2026-05-17-currency-swap-form-design.md` and `../../docs/superpowers/plans/2026-05-17-currency-swap-form.md`. diff --git a/src/problem2/src/App.tsx b/src/problem2/src/App.tsx index 92ec9de094..6db5484170 100644 --- a/src/problem2/src/App.tsx +++ b/src/problem2/src/App.tsx @@ -1,9 +1,65 @@ -export default function App() { +import { usePrices } from "./hooks/usePrices"; +import { SwapForm } from "./components/SwapForm"; + +function LoadingCard() { return ( -
-
- Tailwind + dark theme OK +
+
+
+
+
+
+
+ ); +} + +function ErrorCard({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+ + + + +
+

Couldn't load prices

+

{message}

+ +
+ ); +} + +function BackgroundBlobs() { + return ( +
+
+
); } + +export default function App() { + const { tokens, isLoading, error, retry } = usePrices(); + + return ( + <> + +
+ {isLoading && } + {!isLoading && error && ( + + )} + {!isLoading && !error && tokens.length > 0 && } + {!isLoading && !error && tokens.length === 0 && ( + + )} +
+ + ); +} diff --git a/src/problem2/src/main.tsx b/src/problem2/src/main.tsx index 27481e022f..53388600a1 100644 --- a/src/problem2/src/main.tsx +++ b/src/problem2/src/main.tsx @@ -1,10 +1,22 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { Toaster } from "sonner"; import App from "./App"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( + , ); From 755e0ffa2cd136ee1e5a03fb0c591562650df9cd Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 15:00:10 +0700 Subject: [PATCH 19/21] fix(problem2): honor reduced-motion, flip rotates on click, focus bg --- src/problem2/src/components/FlipButton.tsx | 13 +++++++-- .../src/components/TokenAmountField.tsx | 2 +- src/problem2/src/main.tsx | 27 ++++++++++--------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/problem2/src/components/FlipButton.tsx b/src/problem2/src/components/FlipButton.tsx index d124b5cd65..5b4393b8e2 100644 --- a/src/problem2/src/components/FlipButton.tsx +++ b/src/problem2/src/components/FlipButton.tsx @@ -1,4 +1,5 @@ import { motion } from "framer-motion"; +import { useState } from "react"; type Props = { onClick: () => void; @@ -6,14 +7,22 @@ type Props = { }; export function FlipButton({ onClick, disabled }: Props) { + const [turns, setTurns] = useState(0); + + function handleClick() { + if (disabled) return; + setTurns((t) => t + 1); + onClick(); + } + return ( - - + + + + , ); From 05f148124cb02ac5ab29e02ec7fdb5a19a5fbde1 Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 15:49:39 +0700 Subject: [PATCH 20/21] feat(problem1): three implementations of sum_to_n Adds iterative, closed-form (Gauss), and reduce-based implementations with complexity notes and a small self-check runner. --- src/problem1/sum_to_n.js | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/problem1/sum_to_n.js diff --git a/src/problem1/sum_to_n.js b/src/problem1/sum_to_n.js new file mode 100644 index 0000000000..553a10e642 --- /dev/null +++ b/src/problem1/sum_to_n.js @@ -0,0 +1,64 @@ +/** + * Problem 1: Three ways to sum to n + * + * For any integer `n`, return the summation 1 + 2 + ... + n. + * sum_to_n(5) === 15 + * sum_to_n(0) === 0 + * sum_to_n(-3) === -6 // mirrors the positive case: -(1+2+3) + * + * The problem guarantees the result fits within Number.MAX_SAFE_INTEGER, + * so each implementation is judged purely on time/space complexity and style. + */ + +// Implementation A — iterative loop. +// Time: O(n) Space: O(1) +// The most direct translation of the definition. Easy to read, no recursion +// stack, no allocations. A safe default when the input range is unknown. +var sum_to_n_a = function (n) { + const sign = n < 0 ? -1 : 1; + const limit = Math.abs(n); + let total = 0; + for (let i = 1; i <= limit; i++) { + total += i; + } + return sign * total; +}; + +// Implementation B — closed-form (Gauss) formula. +// Time: O(1) Space: O(1) +// Sum of an arithmetic series: n * (n + 1) / 2. Constant-time regardless of n. +// The formula naturally produces the negated result for negative n +// (e.g. n = -3 → -3 * -2 / 2 = 3 ... we still want -6, see the sign mirror). +// We mirror the sign so sum_to_n_b(-3) === -(1+2+3) === -6, matching A and C. +var sum_to_n_b = function (n) { + const sign = n < 0 ? -1 : 1; + const m = Math.abs(n); + return sign * (m * (m + 1)) / 2; +}; + +// Implementation C — functional reduce over a range. +// Time: O(n) Space: O(n) (the intermediate array) +// Showcases a declarative style: build [1..|n|] and fold it with addition. +// Trades memory for expressiveness; useful when composing with other +// array transforms in a pipeline. +var sum_to_n_c = function (n) { + const sign = n < 0 ? -1 : 1; + const limit = Math.abs(n); + return sign * Array.from({ length: limit }, (_, i) => i + 1) + .reduce((acc, x) => acc + x, 0); +}; + +// --- Quick self-check (run with `node sum_to_n.js`) --- +if (typeof require !== 'undefined' && require.main === module) { + const cases = [0, 1, 5, 10, 100, -5]; + const expected = { 0: 0, 1: 1, 5: 15, 10: 55, 100: 5050, '-5': -15 }; + for (const n of cases) { + const a = sum_to_n_a(n); + const b = sum_to_n_b(n); + const c = sum_to_n_c(n); + const ok = a === expected[n] && b === expected[n] && c === expected[n]; + console.log(`n=${n} a=${a} b=${b} c=${c} ${ok ? 'OK' : 'FAIL'}`); + } +} + +module.exports = { sum_to_n_a, sum_to_n_b, sum_to_n_c }; From d9890ef8bf02c95d0797580cf35d3a6f9bc04dbe Mon Sep 17 00:00:00 2001 From: Linh Ha Date: Sun, 17 May 2026 15:49:45 +0700 Subject: [PATCH 21/21] feat(problem3): refactor of WalletPage with bug + perf write-up Preserves the original buggy component in Original.tsx for reference, documents 6 correctness bugs and TS/perf issues in README.md, and provides a cleaner WalletPage.tsx with hoisted priority table, decorate- sort-undecorate, split filter/format memos, and stable composite keys. --- src/problem3/.keep | 0 src/problem3/Original.tsx | 84 ++++++++++++++++++++++ src/problem3/README.md | 135 ++++++++++++++++++++++++++++++++++++ src/problem3/WalletPage.tsx | 95 +++++++++++++++++++++++++ 4 files changed, 314 insertions(+) delete mode 100644 src/problem3/.keep create mode 100644 src/problem3/Original.tsx create mode 100644 src/problem3/README.md create mode 100644 src/problem3/WalletPage.tsx diff --git a/src/problem3/.keep b/src/problem3/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem3/Original.tsx b/src/problem3/Original.tsx new file mode 100644 index 0000000000..8aea762a81 --- /dev/null +++ b/src/problem3/Original.tsx @@ -0,0 +1,84 @@ +// Original code from the challenge — preserved verbatim for reference. +// See README.md for the full list of issues, and WalletPage.tsx for the refactor. + +interface WalletBalance { + currency: string; + amount: number; +} +interface FormattedWalletBalance { + currency: string; + amount: number; + formatted: string; +} + +interface Props extends BoxProps { + +} +const WalletPage: React.FC = (props: Props) => { + const { children, ...rest } = props; + const balances = useWalletBalances(); + const prices = usePrices(); + + const getPriority = (blockchain: any): number => { + switch (blockchain) { + case 'Osmosis': + return 100 + case 'Ethereum': + return 50 + case 'Arbitrum': + return 30 + case 'Zilliqa': + return 20 + case 'Neo': + return 20 + default: + return -99 + } + } + + const sortedBalances = useMemo(() => { + return balances.filter((balance: WalletBalance) => { + const balancePriority = getPriority(balance.blockchain); + if (lhsPriority > -99) { + if (balance.amount <= 0) { + return true; + } + } + return false + }).sort((lhs: WalletBalance, rhs: WalletBalance) => { + const leftPriority = getPriority(lhs.blockchain); + const rightPriority = getPriority(rhs.blockchain); + if (leftPriority > rightPriority) { + return -1; + } else if (rightPriority > leftPriority) { + return 1; + } + }); + }, [balances, prices]); + + const formattedBalances = sortedBalances.map((balance: WalletBalance) => { + return { + ...balance, + formatted: balance.amount.toFixed() + } + }) + + const rows = sortedBalances.map((balance: FormattedWalletBalance, index: number) => { + const usdValue = prices[balance.currency] * balance.amount; + return ( + + ) + }) + + return ( +
+ {rows} +
+ ) +} diff --git a/src/problem3/README.md b/src/problem3/README.md new file mode 100644 index 0000000000..d9567a8bf7 --- /dev/null +++ b/src/problem3/README.md @@ -0,0 +1,135 @@ +# Problem 3 — Messy React + +Analysis of `Original.tsx` and a refactor in `WalletPage.tsx`. + +The original component has six **bugs that prevent it from working as intended**, plus a number of TypeScript, performance, and React anti-pattern issues. The bugs are listed first because they are the highest-impact: most of the "performance" complaints are moot when the code can't even run. + +--- + +## 1. Bugs (correctness) + +### 1.1 `lhsPriority` is undefined inside the filter callback +```ts +const balancePriority = getPriority(balance.blockchain); +if (lhsPriority > -99) { ... } // ← lhsPriority is not defined +``` +The local is `balancePriority`, but the condition references `lhsPriority`. In TypeScript-strict this is a compile error; at runtime it's a `ReferenceError`. The filter never runs successfully. + +**Fix:** rename to `balancePriority` (or, as in the refactor, replace the `> -99` sentinel check with an explicit `isKnownBlockchain` type guard). + +### 1.2 Filter logic is inverted +```ts +if (balance.amount <= 0) { + return true; // ← keeps zero/negative balances +} +return false; // ← drops positive balances +``` +A wallet page is supposed to show **positive** balances. The condition is the wrong way around. + +**Fix:** `b.amount > 0`. + +### 1.3 `balance.blockchain` is not declared on `WalletBalance` +```ts +interface WalletBalance { + currency: string; + amount: number; + // no `blockchain` field, yet the code does `balance.blockchain` everywhere +} +``` +TypeScript would have caught this — except `getPriority` declares its parameter as `any`, which leaks `any` back through the call site and disables the check. + +**Fix:** add `blockchain: Blockchain` to `WalletBalance` and type `getPriority` properly. + +### 1.4 Sort comparator has no return for equal priorities +```ts +if (leftPriority > rightPriority) return -1; +else if (rightPriority > leftPriority) return 1; +// equal case falls through → returns undefined +``` +`Array.prototype.sort` coerces `undefined` to `0`-ish but the behavior is implementation-defined and produces unstable ordering when many items share a priority (Zilliqa and Neo here both return 20). + +**Fix:** return `0`, or just use `a.priority - b.priority`. + +### 1.5 `formattedBalances` is computed but never used +The `rows` map iterates over `sortedBalances`, not `formattedBalances`. The element is then cast as `FormattedWalletBalance`, which is a lie — `sortedBalances` is `WalletBalance[]`, so `balance.formatted` is always `undefined`. The unsound `as`-cast is the only reason TypeScript doesn't complain. + +**Fix:** iterate over `formattedBalances`, or merge the formatting into a single pass. + +### 1.6 `amount.toFixed()` drops all precision +With no argument `toFixed` defaults to `0` decimals — `1.9999.toFixed()` becomes `"2"`. Almost certainly not the intent for a wallet UI. + +**Fix:** pick a sensible precision such as `toFixed(4)`, or use `Intl.NumberFormat` for locale-aware grouping. + +--- + +## 2. TypeScript issues + +### 2.1 Empty `interface Props extends BoxProps` +Adds nothing — just use `BoxProps` directly (or `type WalletPageProps = BoxProps`). + +### 2.2 Redundant typing on the component +`React.FC` already types `props`. Writing `(props: Props)` on top is redundant. + +### 2.3 `blockchain: any` defeats the type system +The `any` cast on `getPriority` is what allowed bug 1.3 to slip in. Type it as `Blockchain` (a string-literal union) or `string` if the lookup tolerates unknown inputs. + +### 2.4 Unsound cast `(balance: FormattedWalletBalance)` in the row map +`sortedBalances` is `WalletBalance[]`. The cast hides the missing `.formatted` field at compile time, which is how bug 1.5 stayed invisible. + +--- + +## 3. Performance / React anti-patterns + +### 3.1 `getPriority` redefined every render +The switch is pure static data. Hoisting to module scope (or replacing with a `Record`) removes the per-render allocation and lets TypeScript prove exhaustiveness against the union. + +### 3.2 `prices` listed in `useMemo` deps but unused in the body +The memo computes a sorted list from `balances` only — `prices` is not referenced inside. Including it in the deps causes the (potentially expensive) sort to re-run on every price tick for no reason. Common in trading UIs where prices update every second. + +**Fix:** drop `prices` from the deps; if you also want USD values memoized, put them in a separate memo with `[sortedBalances, prices]` so a price tick only re-runs the cheap formatting step. + +### 3.3 `getPriority` called twice per comparison in the sort +The comparator calls `getPriority` for both sides every time it fires, so the function runs ~2·N·log N times per sort. With a static lookup this is fine in absolute terms, but the canonical fix — the *decorate-sort-undecorate* (Schwartzian) pattern — is one line and makes the intent obvious. + +**Fix:** map to `{ balance, priority }`, sort by `priority`, then unwrap. + +### 3.4 `formattedBalances` is not memoized +Even if it were used, it gets recomputed on every render — including renders triggered by unrelated state. Wrap in `useMemo` with `[sortedBalances]`. + +### 3.5 `key={index}` is the wrong key +React keys must be stable identifiers of the item, not of its position. The list here is filtered + sorted, so the same balance will land at different indices across renders, and React will reuse the wrong DOM nodes (carrying focus, animation state, etc. across rows). Use a stable id like `${blockchain}:${currency}`. + +### 3.6 Several passes over `balances` that can be combined +filter → sort → map (formattedBalances) → map (rows). Combining the filter+sort+format into a single pipeline reduces allocations and makes the data flow easier to follow. (Whether to split filtering and formatting into two memos is a deliberate trade-off — see 3.2.) + +### 3.7 No null/undefined safety on `prices[balance.currency]` +If a token has no price, `prices[currency]` is `undefined`, and `undefined * amount` is `NaN`. The UI then renders `$NaN`. + +**Fix:** `(prices[currency] ?? 0) * amount`, or skip the row entirely. + +--- + +## 4. Smaller issues + +- **Magic `-99` sentinel.** Use `Number.NEGATIVE_INFINITY`, or — cleaner — a type guard like `isKnownBlockchain` so the filter expresses intent directly. +- **`children` destructured but never rendered.** Either render `{children}` (as in the refactor) or remove the destructure. +- **Mixed indentation.** Tabs and 2-space spaces interleaved in the original. A formatter (Prettier) would silence this. + +--- + +## 5. Refactor summary + +`WalletPage.tsx` applies the fixes above: + +| Concern | Before | After | +| -------------------- | ------------------------------------- | -------------------------------------------------------------- | +| Filter bug | wrong variable, inverted predicate | `isKnownBlockchain(b.blockchain) && b.amount > 0` | +| Sort | if/else, missing equal case | `b.priority - a.priority` | +| Priority lookup | function redefined per render | module-scope `Record` | +| Priority calls/sort | 2·N·log N | N (decorate-sort-undecorate) | +| Memo deps | `[balances, prices]` (over-triggered) | `[balances]` for sort, `[sortedBalances, prices]` for format | +| Types | `any`, empty `Props`, unsound cast | `Blockchain` union, `WalletPageProps = BoxProps`, inferred map | +| Key | `index` | `${blockchain}:${currency}` | +| Missing price | `NaN` USD | `(prices[currency] ?? 0)` | +| `.toFixed()` | 0 decimals | `toFixed(4)` | +| `children` | destructured, dropped | rendered | diff --git a/src/problem3/WalletPage.tsx b/src/problem3/WalletPage.tsx new file mode 100644 index 0000000000..c3c1fb7f70 --- /dev/null +++ b/src/problem3/WalletPage.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import type { FC } from 'react'; +// The following imports are placeholders — keep the originals from your project. +// import type { BoxProps } from '@mui/material'; +// import { useWalletBalances } from './hooks/useWalletBalances'; +// import { usePrices } from './hooks/usePrices'; +// import { WalletRow } from './WalletRow'; +// import classes from './WalletPage.module.css'; + +// --- Types ------------------------------------------------------------------ + +type Blockchain = 'Osmosis' | 'Ethereum' | 'Arbitrum' | 'Zilliqa' | 'Neo'; + +interface WalletBalance { + currency: string; + amount: number; + blockchain: Blockchain; // missing in the original — added so TS can check usage +} + +interface FormattedWalletBalance extends WalletBalance { + formatted: string; + usdValue: number; +} + +// --- Static lookup --------------------------------------------------------- + +// Hoisted to module scope: the lookup is pure data, so there is no reason to +// reallocate it on every render. Using a Record also gives us O(1) lookup with +// exhaustive type-checking against the Blockchain union. +const BLOCKCHAIN_PRIORITY: Record = { + Osmosis: 100, + Ethereum: 50, + Arbitrum: 30, + Zilliqa: 20, + Neo: 20, +}; + +const isKnownBlockchain = (b: string): b is Blockchain => + Object.prototype.hasOwnProperty.call(BLOCKCHAIN_PRIORITY, b); + +const getPriority = (b: Blockchain): number => BLOCKCHAIN_PRIORITY[b]; + +// --- Component -------------------------------------------------------------- + +type WalletPageProps = BoxProps; + +export const WalletPage: FC = ({ children, ...rest }) => { + const balances = useWalletBalances(); + const prices = usePrices(); + + // Sort + filter. Decorate-sort-undecorate so getPriority runs once per + // balance rather than ~2·N·logN times inside the comparator. + // Deps: balances only — prices do not affect ordering. + const sortedBalances = useMemo(() => { + return balances + .filter( + (b): b is WalletBalance => + isKnownBlockchain(b.blockchain) && b.amount > 0, + ) + .map((b) => ({ balance: b, priority: getPriority(b.blockchain) })) + .sort((a, b) => b.priority - a.priority) // descending + .map(({ balance }) => balance); + }, [balances]); + + // Format pass kept separate so a price tick re-runs only the formatting, + // not the filter/sort. + const formattedBalances = useMemo(() => { + return sortedBalances.map((b) => { + const price = prices[b.currency] ?? 0; + return { + ...b, + formatted: b.amount.toFixed(4), + usdValue: price * b.amount, + }; + }); + }, [sortedBalances, prices]); + + return ( +
+ {formattedBalances.map((b) => ( + + ))} + {children} +
+ ); +};