From 76e04477ef879dc01ea7d20380d2abe81f724fa4 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 00:15:53 +0200 Subject: [PATCH 01/17] chore: gitignore .codequill/ (CodeQuill local snapshot cache) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index dc62afe..4ef7970 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ typechain-types/ # Local prep & internal notes (not part of the public repo) /notes/ + +# CodeQuill local snapshot cache (manifests are pinned to IPFS on publish) +.codequill/ From 00eae21133a81f9473ed45d4593d32a7ecc0b205 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:03:47 +0200 Subject: [PATCH 02/17] docs: design spec for Discover floating sector coins --- ...2026-06-17-floating-sector-coins-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md diff --git a/docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md b/docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md new file mode 100644 index 0000000..d92a9ff --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md @@ -0,0 +1,185 @@ +# Discover — Floating Sector Coins + +- **Date:** 2026-06-17 +- **Status:** Approved (design) — pending implementation plan +- **Scope:** Web app only (`web/`). New in-app surface + one decorative-but-interactive component family. No contract, hook, or business-logic changes. + +## 1. Context & problem + +The Wafer investor dApp (`Vite 6 + React 19 + viem`) currently lands a connected +user on the **Deposit** tab. There is no visual entry point that communicates the +breadth of the protocol (five DePIN sectors) or invites discovery. + +We want a Uniswap-style **floating coins** hero: a small set of premium, drifting, +hoverable tokens. Each token represents a DePIN **sector**; hovering reveals the +sector's best yield; clicking opens a menu of that sector's risk-class pools, each +of which routes straight into the existing Deposit flow. + +The effect is the artistic direction borrowed from reference projects; **all +existing buttons, handlers, and on-chain logic stay untouched**. We only add a new +view and reuse existing handlers. + +## 2. Goals / non-goals + +**Goals** +- A dedicated in-app **Discover** view that becomes the connected landing. +- Five floating sector coins (GPU, Wireless, Mapping, Energy, Storage). +- Hover → capsule showing sector label + **max APR** across its pools. +- Click → dropdown of the sector's risk classes (A/B/C) with per-class APR; clicking + a row opens that exact pool in **Deposit** via the existing `onOpenDeposit(poolId)`. +- Clean, small, single-purpose components; pure, testable data layer. Built to scale. +- Graceful degradation when a sector or class has no pool. +- Respect `prefers-reduced-motion`. + +**Non-goals** +- No changes to contracts, ABIs, `useContracts`, `useWallet`, or any read/write path. +- No changes to the marketing Hero/landing (`Hero.jsx`). +- No physics engine, no WebGL/3D, no canvas (see §4). +- Not a dense token cloud — exactly five deliberate hero coins. + +## 3. Research convergence (why this shape) + +A multi-agent survey dissected the user's reference repo `carluzh/canary` and the +real `Uniswap/interface`. Both implement floating coins the **same way**: plain +**DOM + CSS** (absolutely-positioned elements, blue-noise/Poisson layout, hover via +CSS), with **no canvas, no WebGL, no physics, and no JS animation loop**. canary's +coins are in fact static until hovered. Key reusable ideas: deterministic layout, +per-coin depth variation (tilt/opacity), a staggered mount-in, a pointer-events:none +wrapper so only the coins are interactive (page content underneath stays clickable), +and a hover capsule that reveals metadata. + +## 4. Chosen approach + +**DOM + CSS coins, deliberately placed in an orbit around the Wafer wordmark, +animated with `motion` (formerly framer-motion).** + +The interaction model is decisive: a **click-expandable menu with three clickable +rows** is rich DOM interaction (focus, keyboard, hit targets, accessibility). DOM is +the correct medium. A canvas approach was rejected because per-row click hit-testing +and accessibility on a canvas are fragile and don't scale. + +Rejected alternatives: +- **Verbatim canary port (Poisson scatter + `poisson-disk-sampling`):** random + scatter reads as less intentional for exactly five hero coins, is static by + default, and we'd still bolt on the dropdown. More ported code than needed. +- **Canvas field:** wrong medium for a precise, accessible click menu (above). + +## 5. Architecture + +Small units, one purpose each, communicating through explicit props. + +| Unit | File | Responsibility | Depends on | +|------|------|----------------|------------| +| `Discover` (view) | `web/src/components/Discover.jsx` | Fetch pools+deals, derive sectors, render the hero shell (centered wordmark + coin field) | `contracts`, `refreshKey`, `onOpenDeposit`; `sectors.js` | +| `SectorCoins` | `web/src/components/discover/SectorCoins.jsx` | Orbit layout of the five coins + global pointer-parallax tilt | `SectorCoin` | +| `SectorCoin` | `web/src/components/discover/SectorCoin.jsx` | One coin: idle float, hover capsule (label + max APR), click dropdown (A/B/C rows) | `onOpenDeposit` | +| `sectors` (pure) | `web/src/lib/sectors.js` | `groupBySector(pools, deals)` → per-category `{ category, label, logo, classes[], maxApr }` | `config.js`, `format.js` | +| styles | `web/src/components/discover/discover.css` | Navy/amber re-skin, float keyframes, reduced-motion guard | — | + +### Data shape produced by `groupBySector` + +```js +// One entry per category (all 5 always present, even with no pools). +{ + category: 0, // enum index + label: "GPU / Compute", // CATEGORY_LABEL + logo: "/logos/gpu.png", // CATEGORY_LOGO + classes: [ // only classes that have a pool, sorted A,B,C + { risk: "A", poolId: 3, apr: 0.091 }, + { risk: "B", poolId: 4, apr: 0.124 }, + { risk: "C", poolId: 5, apr: 0.142 }, + ], + maxApr: 0.142, // max of non-null class aprs, or null +} +``` + +APR per class reuses the **existing** logic already in `Explore.jsx` +(`poolTrailingApr(poolId, deals)` / `dealApr`). That logic will be lifted into +`sectors.js` (or imported) so there is a single source of truth — no re-derivation. + +## 6. Interaction model + +1. **Idle:** each coin floats (gentle translateY bob + micro-rotation), desynced + per coin so the field feels alive but calm. A subtle whole-field parallax tilts + toward the cursor. +2. **Hover:** the coin lifts/sharpens, gains a warm amber glow, and a capsule + appears: `GPU / Compute · max 14.2%`. +3. **Click:** the capsule expands into a menu (one coin open at a time): + ``` + GPU / Compute + ────────────────── + Class A · 9.1% → + Class B · 12.4% → + Class C · 14.2% → + ``` + Clicking a row calls `onOpenDeposit(poolId)` → opens that exact pool in the + existing Deposit card. Esc or outside-click closes the menu. + +**Degradation:** sector with no pools → coin renders, capsule shows `—`, menu +disabled. Missing class → that row is omitted. `maxApr === null` → capsule shows `—`. + +**Accessibility:** coins are real ` + + ))} + + )} + + + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm -C web test src/components/discover/SectorCoin.test.jsx` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/discover/SectorCoin.jsx web/src/components/discover/SectorCoin.test.jsx +git commit -m "feat(web): SectorCoin — floating sector coin with hover APR + class menu" +``` + +--- + +### Task 5: `SectorCoins` orbit layout + +**Files:** +- Create: `web/src/components/discover/layout.js` +- Test: `web/src/components/discover/layout.test.js` +- Create: `web/src/components/discover/SectorCoins.jsx` +- Test: `web/src/components/discover/SectorCoins.test.jsx` + +- [ ] **Step 1: Write the failing layout test** + +Create `web/src/components/discover/layout.test.js`: +```js +import { describe, it, expect } from "vitest"; +import { orbitPositions } from "./layout.js"; + +describe("orbitPositions", () => { + it("returns one position per coin", () => { + expect(orbitPositions(5)).toHaveLength(5); + expect(orbitPositions(0)).toEqual([]); + }); + + it("places the first coin at top-center of the box", () => { + const [first] = orbitPositions(5, 34); + expect(first.x).toBeCloseTo(50, 5); + expect(first.y).toBeCloseTo(16, 5); // 50 - radius + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm -C web test src/components/discover/layout.test.js` +Expected: FAIL — cannot resolve `./layout.js`. + +- [ ] **Step 3: Implement `layout.js`** + +Create `web/src/components/discover/layout.js`: +```js +// Deterministic orbit placement for N coins, as percentage offsets inside the hero +// box. Coin 0 starts at top-center, going clockwise. Layout is derived from the +// count, so adding a sector rebalances the ring automatically. +export function orbitPositions(count, radius = 34) { + if (count <= 0) return []; + return Array.from({ length: count }, (_, i) => { + const angle = (-90 + (360 / count) * i) * (Math.PI / 180); + return { + x: 50 + radius * Math.cos(angle), + y: 50 + radius * Math.sin(angle), + }; + }); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm -C web test src/components/discover/layout.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Write the failing SectorCoins test** + +Create `web/src/components/discover/SectorCoins.test.jsx`: +```jsx +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import SectorCoins from "./SectorCoins.jsx"; + +const sectors = [ + { category: 0, label: "GPU / Compute", logo: "/logos/gpu.png", classes: [{ risk: "A", poolId: 0, apr: 0.1 }], maxApr: 0.1 }, + { category: 1, label: "Wireless", logo: "/logos/wireless.png", classes: [], maxApr: null }, +]; + +describe("SectorCoins", () => { + it("renders one coin button per sector", () => { + render( {}} />); + expect(screen.getAllByRole("button")).toHaveLength(2); + expect(screen.getByText("GPU / Compute")).toBeInTheDocument(); + expect(screen.getByText("Wireless")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Run to verify it fails** + +Run: `pnpm -C web test src/components/discover/SectorCoins.test.jsx` +Expected: FAIL — cannot resolve `./SectorCoins.jsx`. + +- [ ] **Step 7: Implement `SectorCoins.jsx`** + +Create `web/src/components/discover/SectorCoins.jsx`: +```jsx +import { useRef } from "react"; +import SectorCoin from "./SectorCoin.jsx"; +import { orbitPositions } from "./layout.js"; + +// Lays the coins out in an orbit and applies a subtle whole-field parallax tilt +// toward the pointer (rAF-batched, written to CSS vars; the actual tilt lives in +// discover.css and is disabled under prefers-reduced-motion). +export default function SectorCoins({ sectors, onOpenDeposit }) { + const ref = useRef(null); + const frame = useRef(0); + const positions = orbitPositions(sectors.length); + + const onPointerMove = (e) => { + const el = ref.current; + if (!el) return; + cancelAnimationFrame(frame.current); + frame.current = requestAnimationFrame(() => { + const r = el.getBoundingClientRect(); + if (!r.width || !r.height) return; + const dx = (e.clientX - (r.left + r.width / 2)) / r.width; // -0.5 … 0.5 + const dy = (e.clientY - (r.top + r.height / 2)) / r.height; + el.style.setProperty("--px", dx.toFixed(3)); + el.style.setProperty("--py", dy.toFixed(3)); + }); + }; + + return ( +
+ {sectors.map((sector, i) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 8: Run to verify it passes** + +Run: `pnpm -C web test src/components/discover/SectorCoins.test.jsx` +Expected: PASS (1 test). + +- [ ] **Step 9: Commit** + +```bash +git add web/src/components/discover/layout.js web/src/components/discover/layout.test.js web/src/components/discover/SectorCoins.jsx web/src/components/discover/SectorCoins.test.jsx +git commit -m "feat(web): SectorCoins orbit layout + pointer parallax" +``` + +--- + +### Task 6: `Discover` view + +**Files:** +- Create: `web/src/components/Discover.jsx` +- Test: `web/src/components/Discover.test.jsx` +- Create: `web/src/components/discover/discover.css` (empty placeholder so the import resolves; filled in Task 8) + +- [ ] **Step 1: Create the empty stylesheet so the import resolves** + +Create `web/src/components/discover/discover.css`: +```css +/* Discover hero + floating coin visuals — filled in Task 8. */ +``` + +- [ ] **Step 2: Write the failing test** + +Create `web/src/components/Discover.test.jsx`: +```jsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Discover from "./Discover.jsx"; + +const ADV = 100_00000000n; +const YEAR = 31_536_000n; +const mockContracts = { + getPools: vi.fn().mockResolvedValue([ + { poolId: 0, category: 0, class: 0 }, + { poolId: 1, category: 0, class: 1 }, + ]), + getDeals: vi.fn().mockResolvedValue([ + { poolId: 0, status: 4, advance: ADV, expected: 110_00000000n, term: YEAR }, // 0.10 + { poolId: 1, status: 4, advance: ADV, expected: 120_00000000n, term: YEAR }, // 0.20 + ]), +}; + +describe("Discover", () => { + it("renders all five sector coins", () => { + render( {}} />); + // groupBySector always yields 5 sectors, so 5 coins render before data resolves. + expect(screen.getAllByRole("button")).toHaveLength(5); + }); + + it("shows the fetched max APR for a sector after load", async () => { + render( {}} />); + expect(await screen.findByText("max 20.00%")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `pnpm -C web test src/components/Discover.test.jsx` +Expected: FAIL — cannot resolve `./Discover.jsx`. + +- [ ] **Step 4: Implement `Discover.jsx`** + +Create `web/src/components/Discover.jsx`: +```jsx +import { useEffect, useState } from "react"; +import SectorCoins from "./discover/SectorCoins.jsx"; +import { groupBySector } from "../lib/sectors.js"; +import "./discover/discover.css"; + +// Discover — the connected-app hero. Reads pools + deals (same source as Explore), +// groups them into the five DePIN sectors, and renders them as floating coins. +// Purely additive: it reuses the existing onOpenDeposit handler and touches no +// contract logic. +export default function Discover({ contracts, refreshKey, onOpenDeposit }) { + const [pools, setPools] = useState([]); + const [deals, setDeals] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!contracts) return; + let cancelled = false; + (async () => { + try { + const [list, dealList] = await Promise.all([contracts.getPools(), contracts.getDeals()]); + if (cancelled) return; + setPools(list); + setDeals(dealList); + } catch { /* keep last good data */ } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [contracts, refreshKey]); + + const sectors = groupBySector(pools, deals); + + return ( +
+
+ Wafer +

Hover a sector for its best yield · click to pick a pool

+
+ + {loading &&
Loading sectors…
} +
+ ); +} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `pnpm -C web test src/components/Discover.test.jsx` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/components/Discover.jsx web/src/components/Discover.test.jsx web/src/components/discover/discover.css +git commit -m "feat(web): Discover hero view wiring sector data to coins" +``` + +--- + +### Task 7: Route Discover as the connected landing + +**Files:** +- Modify: `web/src/App.jsx` +- Modify: `web/src/components/TopNav.jsx` + +- [ ] **Step 1: Add the Discover import to `App.jsx`** + +In `web/src/App.jsx`, after the line: +```js +import Explore from "./components/Explore.jsx"; +``` +add: +```js +import Discover from "./components/Discover.jsx"; +``` + +- [ ] **Step 2: Register the `discover` tab** + +In `web/src/App.jsx`, change: +```js +const INVESTOR_TABS = new Set(["home", "deposit", "explore", "dashboard", "operator", "activity", "queue"]); +``` +to: +```js +const INVESTOR_TABS = new Set(["home", "discover", "deposit", "explore", "dashboard", "operator", "activity", "queue"]); +``` + +- [ ] **Step 3: Land on Discover after connect** + +In `web/src/App.jsx`, in the auto-navigate effect, change: +```js + setWalletModalOpen(false); + setTab((t) => (t === "home" ? "deposit" : t)); +``` +to: +```js + setWalletModalOpen(false); + setTab((t) => (t === "home" ? "discover" : t)); +``` + +- [ ] **Step 4: Point `goApp` at Discover** + +In `web/src/App.jsx`, change: +```js + const goApp = useCallback(() => { + if (account) setTab("deposit"); + else openWalletModal(); + }, [account, openWalletModal]); +``` +to: +```js + const goApp = useCallback(() => { + if (account) setTab("discover"); + else openWalletModal(); + }, [account, openWalletModal]); +``` + +- [ ] **Step 5: Render the Discover branch** + +In `web/src/App.jsx`, inside ``, immediately before the `{tab === "deposit" && (` block, add: +```jsx + {tab === "discover" && ( + + )} +``` + +- [ ] **Step 6: Add the Discover nav item to `TopNav.jsx`** + +In `web/src/components/TopNav.jsx`, change: +```js + const nav = [ + { id: "deposit", label: "Deposit" }, + { id: "explore", label: "Explore" }, + { id: "dashboard", label: "Portfolio" }, + ]; +``` +to: +```js + const nav = [ + { id: "discover", label: "Discover" }, + { id: "deposit", label: "Deposit" }, + { id: "explore", label: "Explore" }, + { id: "dashboard", label: "Portfolio" }, + ]; +``` + +- [ ] **Step 7: Verify build + full test run** + +Run: `pnpm -C web build && pnpm -C web test` +Expected: build succeeds; all tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/App.jsx web/src/components/TopNav.jsx +git commit -m "feat(web): route Discover as connected landing + nav item" +``` + +--- + +### Task 8: Discover hero + coin visuals (navy/amber, reduced-motion safe) + +**Files:** +- Modify: `web/src/components/discover/discover.css` + +- [ ] **Step 1: Fill in the stylesheet** + +Replace the entire contents of `web/src/components/discover/discover.css` with: +```css +/* Discover hero — floating DePIN sector coins. Decorative layer; the coins are the + only interactive elements. Palette tracks the app: deep navy bg + amber accent. */ + +.discover-hero { + position: relative; + width: 100%; + min-height: clamp(420px, 70vh, 760px); + overflow: visible; +} + +/* Centered wordmark + tagline, behind the coins. */ +.discover-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + text-align: center; + pointer-events: none; + z-index: 1; +} +.discover-wordmark { height: 40px; width: auto; opacity: 0.95; } +.discover-tagline { + margin: 0; + font-size: 0.85rem; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.55); +} +.discover-loading { + position: absolute; + bottom: 1rem; left: 50%; + transform: translateX(-50%); + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.45); + z-index: 1; +} + +/* The coin field: pointer-events:none so only coins capture input; a subtle 3D + parallax tilt is driven by --px/--py written from SectorCoins. */ +.coins-field { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + transform: perspective(1100px) + rotateY(calc(var(--px, 0) * 6deg)) + rotateX(calc(var(--py, 0) * -6deg)); + transform-style: preserve-3d; + transition: transform 0.25s ease-out; +} + +/* A single coin slot. The float lives here so it composes with the inner button's + hover-scale (set by motion) without the two transforms fighting. */ +.coin { + position: absolute; + pointer-events: auto; + animation: coin-float var(--float-dur, 8s) ease-in-out infinite; + animation-delay: var(--float-delay, 0s); + will-change: transform; +} +@keyframes coin-float { + 0%, 100% { transform: translate(-50%, -50%) rotate(var(--tilt, 0deg)); } + 50% { transform: translate(-50%, calc(-50% - 12px)) rotate(calc(var(--tilt, 0deg) * -1)); } +} + +/* The coin button itself. */ +.coin-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 0; + background: none; + border: 0; + cursor: pointer; + color: #fff; +} +.coin-btn:disabled { cursor: default; opacity: 0.5; } +.coin-btn:focus-visible { outline: 2px solid #f59e0b; outline-offset: 6px; border-radius: 12px; } + +.coin-img { + width: clamp(56px, 7vw, 84px); + height: auto; + filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.5)); + transition: filter 0.18s ease; +} +.coin-btn:hover:not(:disabled) .coin-img { + filter: drop-shadow(0 10px 26px rgba(245, 158, 11, 0.45)); +} + +/* Hover capsule (label + max APR). Hidden until hover/focus. */ +.coin-cap { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: rgba(10, 12, 18, 0.85); + border: 1px solid rgba(245, 158, 11, 0.35); + backdrop-filter: blur(6px); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.18s ease, transform 0.18s ease; + white-space: nowrap; +} +.coin-btn:hover:not(:disabled) .coin-cap, +.coin-btn:focus-visible .coin-cap, +.coin-btn[aria-expanded="true"] .coin-cap { + opacity: 1; + transform: translateY(0); +} +.coin-cap-label { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.01em; } +.coin-cap-apr { font-size: 0.72rem; color: #f59e0b; font-variant-numeric: tabular-nums; } + +/* Click-expanded menu of the sector's risk-class pools. */ +.coin-menu { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + margin: 0; + padding: 0.35rem; + list-style: none; + min-width: 168px; + border-radius: 12px; + background: rgba(10, 12, 18, 0.96); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5); + z-index: 6; +} +.coin-menu-title { + padding: 0.3rem 0.6rem 0.4rem; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.45); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + margin-bottom: 0.25rem; +} +.coin-menu-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + width: 100%; + padding: 0.45rem 0.6rem; + background: none; + border: 0; + border-radius: 8px; + cursor: pointer; + color: #fff; + font-size: 0.8rem; +} +.coin-menu-row:hover { background: rgba(245, 158, 11, 0.14); } +.coin-menu-class { color: rgba(255, 255, 255, 0.8); } +.coin-menu-apr { color: #f59e0b; font-variant-numeric: tabular-nums; } + +@media (prefers-reduced-motion: reduce) { + .coin { + animation: none; + transform: translate(-50%, -50%) rotate(var(--tilt, 0deg)); + } + .coins-field { transform: none; transition: none; } +} +``` + +- [ ] **Step 2: Verify build + tests still pass** + +Run: `pnpm -C web build && pnpm -C web test` +Expected: build succeeds; all tests PASS (CSS does not affect tests). + +- [ ] **Step 3: Commit** + +```bash +git add web/src/components/discover/discover.css +git commit -m "style(web): Discover hero + floating coin visuals (reduced-motion safe)" +``` + +--- + +### Task 9: Manual verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the dev server** + +Run: `pnpm -C web dev` +Open the printed local URL. + +- [ ] **Step 2: Verify the Discover landing** + +Connect a wallet (or use the existing connect flow). Confirm: +- After connect you land on **Discover** (not Deposit), and **Discover** is the active nav item. +- Five coins (GPU, Wireless, Mapping, Energy, Storage) float gently around the Wafer wordmark. +- Hovering a coin lifts it, glows amber, and shows the `… · max X%` capsule. +- Clicking a coin opens the A/B/C menu; clicking a row opens that exact pool in **Deposit**. +- A sector with no pools shows `—` and its coin is not clickable. +- Moving the cursor tilts the field subtly (parallax). +- The other tabs (Deposit, Explore, Portfolio) and their buttons behave exactly as before. + +- [ ] **Step 3: Verify reduced-motion** + +Enable OS "Reduce motion" and reload. Confirm coins are static (no float, no parallax) but hover/click still work. + +- [ ] **Step 4: Final full check + commit (if any tweaks were made)** + +Run: `pnpm -C web test && pnpm -C web build` +Expected: all tests PASS; build succeeds. +```bash +git add -A +git commit -m "chore(web): verify Discover floating sector coins" +``` + +--- + +## Self-review + +**Spec coverage:** +- §5 architecture (Discover, SectorCoins, SectorCoin, sectors.js, css) → Tasks 2, 4, 5, 6, 8. ✓ +- §5 `groupBySector` shape (5 sectors, sorted classes, maxApr, degradation) → Task 2 tests. ✓ +- §6 interaction (idle float, hover capsule, click menu → onOpenDeposit, Esc/outside close, degradation, a11y) → Tasks 4, 8. ✓ +- §7 routing (INVESTOR_TABS, default landing, render branch, TopNav item) → Task 7. ✓ +- §8 visual/motion + prefers-reduced-motion → Task 8. ✓ +- §9 deps (`motion`) + placeholder logos → Task 3; logos come from `CATEGORY_LOGO`. ✓ +- §10 single source of truth (Explore consumes shared helper) → Task 2 Step 5. ✓ +- §11 only App/TopNav/Explore touched among existing files → Tasks 2, 7. ✓ +- §12 testing (pure lib unit-tested; manual checklist) → Tasks 2, 4, 5, 6, 9. ✓ + +**Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to Task N". Every code step shows full code. The Task 6 Step 1 empty CSS is intentional (filled in Task 8) and labeled. ✓ + +**Type/name consistency:** `poolTrailingApr(poolId, deals)` and `groupBySector(pools, deals)` are defined in Task 2 and consumed identically in Tasks 5/6 and Explore. Sector object keys (`category`, `label`, `logo`, `classes[{risk,poolId,apr}]`, `maxApr`) match across `sectors.js`, `SectorCoin`, `SectorCoins`, `Discover`, and all test fixtures. `orbitPositions(count, radius)` defined and used consistently. `onOpenDeposit(poolId)` matches the existing App handler signature. CSS vars `--px/--py/--float-delay/--float-dur/--tilt` are written in `SectorCoins.jsx` and read in `discover.css`. ✓ diff --git a/docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md b/docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md index d92a9ff..2e6b39d 100644 --- a/docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md +++ b/docs/superpowers/specs/2026-06-17-floating-sector-coins-design.md @@ -164,9 +164,15 @@ Additive only — no handler is modified. ## 11. What is explicitly NOT touched Contracts, ABIs, `useContracts`, `useWallet`, `DepositCard`/`DepositWidget`, -`Explore`, `Portfolio`, `OperatorPortal`, `Admin`, and every existing handler -(`onOpenDeposit`, `onTabChange`, `connect`, etc.). The only edits to existing files -are the additive routing wiring in `App.jsx` and a nav item in `TopNav.jsx`. +`Portfolio`, `OperatorPortal`, `Admin`, and every existing handler (`onOpenDeposit`, +`onTabChange`, `connect`, etc.). + +Edits to existing files are limited to: +- `App.jsx` — additive routing wiring (new `discover` tab, default landing). +- `TopNav.jsx` — one nav item. +- `Explore.jsx` — **pure refactor only**: its private `poolTrailingApr` helper moves + into `sectors.js` and `Explore` imports it instead (realizes §10's single source of + truth). No behavior change; a test locks the extracted function's output. ## 12. Testing From f468dbba773351e563c3ccf08122790e99f091b8 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:17:01 +0200 Subject: [PATCH 04/17] test(web): add vitest + react-testing-library harness --- web/package.json | 12 +- web/pnpm-lock.yaml | 761 ++++++++++++++++++++++++++++++++++++++++++ web/src/test/setup.js | 15 + web/vite.config.js | 7 +- 4 files changed, 792 insertions(+), 3 deletions(-) create mode 100644 web/src/test/setup.js diff --git a/web/package.json b/web/package.json index 62c2a56..73dff08 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^19.1.0", @@ -15,7 +17,13 @@ "viem": "^2.47.0" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^4.4.1", - "vite": "^6.3.1" + "jsdom": "^29.1.1", + "vite": "^6.3.1", + "vitest": "^4.1.9" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index aa2aae1..7c9ffb2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -18,18 +18,54 @@ importers: specifier: ^2.47.0 version: 2.52.2 devDependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@vitejs/plugin-react': specifier: ^4.4.1 version: 4.7.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) vite: specifier: ^6.3.1 version: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + vitest: + specifier: ^4.1.9 + version: 4.1.9(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -101,6 +137,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -113,6 +153,46 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.7': + resolution: {integrity: sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -269,6 +349,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -447,6 +536,41 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -459,6 +583,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -468,6 +598,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -479,11 +638,33 @@ packages: zod: optional: true + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + baseline-browser-mapping@2.10.37: resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -492,9 +673,24 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -504,13 +700,33 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.372: resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -520,9 +736,16 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -541,6 +764,17 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: @@ -553,6 +787,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -637,9 +880,27 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -652,6 +913,10 @@ packages: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + ox@0.14.29: resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} peerDependencies: @@ -660,6 +925,12 @@ packages: typescript: optional: true + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -671,11 +942,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: react: ^19.2.7 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -684,11 +966,23 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -696,14 +990,60 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -758,6 +1098,68 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.20.1: resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} @@ -770,13 +1172,42 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} snapshots: + '@adobe/css-tools@4.5.0': {} + '@adraffy/ens-normalize@1.11.1': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -866,6 +1297,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -889,6 +1322,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -967,6 +1428,10 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1084,6 +1549,41 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -1105,6 +1605,13 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))': @@ -1119,10 +1626,67 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + abitype@1.2.3: {} + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + baseline-browser-mapping@2.10.37: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.37 @@ -1133,17 +1697,45 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@6.2.2: {} + convert-source-map@2.0.0: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + detect-libc@2.1.2: optional: true + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.372: {} + entities@8.0.0: {} + + es-module-lexer@2.1.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -1175,8 +1767,14 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventemitter3@5.0.1: {} + expect-type@1.3.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1186,6 +1784,16 @@ snapshots: gensync@1.0.0-beta.2: {} + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + isows@1.0.7(ws@8.20.1): dependencies: ws: 8.20.1 @@ -1195,6 +1803,32 @@ snapshots: js-tokens@4.0.0: {} + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json5@2.2.3: {} @@ -1249,16 +1883,30 @@ snapshots: lightningcss-win32-x64-msvc: 1.32.0 optional: true + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + ms@2.1.3: {} nanoid@3.3.12: {} node-releases@2.0.47: {} + obug@2.1.3: {} + ox@0.14.29: dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -1272,6 +1920,12 @@ snapshots: transitivePeerDependencies: - zod + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -1282,15 +1936,32 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.7: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + rollup@4.62.0: dependencies: '@types/estree': 1.0.9 @@ -1322,17 +1993,55 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + + tldts-core@7.4.3: {} + + tldts@7.4.3: + dependencies: + tldts-core: 7.4.3 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.3 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + undici@7.28.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -1367,6 +2076,58 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 + vitest@4.1.9(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 29.1.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.20.1: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} diff --git a/web/src/test/setup.js b/web/src/test/setup.js new file mode 100644 index 0000000..48d261d --- /dev/null +++ b/web/src/test/setup.js @@ -0,0 +1,15 @@ +import "@testing-library/jest-dom"; + +// jsdom has no matchMedia; motion/react reads it for reduced-motion detection. +if (!window.matchMedia) { + window.matchMedia = (query) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); +} diff --git a/web/vite.config.js b/web/vite.config.js index 43ce606..1f93184 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; export default defineConfig({ @@ -16,4 +16,9 @@ export default defineConfig({ allow: [".."], }, }, + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test/setup.js", + }, }); From 7fc068b1f55d420f6859fc8c4fa5e16c587ae293 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:18:05 +0200 Subject: [PATCH 05/17] refactor(web): extract sector/APR grouping into lib/sectors.js --- web/src/components/Explore.jsx | 12 +------ web/src/lib/sectors.js | 45 ++++++++++++++++++++++++++ web/src/lib/sectors.test.js | 59 ++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/sectors.js create mode 100644 web/src/lib/sectors.test.js diff --git a/web/src/components/Explore.jsx b/web/src/components/Explore.jsx index 629a210..1d9fe35 100644 --- a/web/src/components/Explore.jsx +++ b/web/src/components/Explore.jsx @@ -7,6 +7,7 @@ import { VAULT_CONFIGURED, CATEGORY_LABEL, CATEGORY_LOGO, RISK_CLASSES, DEAL_STATUS, poolDisplayName, EXPLORER_URL, } from "../lib/config.js"; import { formatHbar, formatNav, formatPercent, formatTerm, dealApr, shortHash } from "../lib/format.js"; +import { poolTrailingApr } from "../lib/sectors.js"; // Explore — the protocol's market surface, Uniswap-style. // KPI row (TVL, deployed, idle, pools, blended APR) over contract reads, then @@ -14,17 +15,6 @@ import { formatHbar, formatNav, formatPercent, formatTerm, dealApr, shortHash } // expected, term, implied APR, status), Activity (Mirror Node event feed). // `search` filters the Pools and Deals tables by name/category. -function poolTrailingApr(poolId, deals) { - const financed = deals.filter((d) => d.poolId === poolId && (d.status === 3 || d.status === 4)); - let wSum = 0, num = 0; - for (const d of financed) { - const apr = dealApr(d.advance, d.expected, d.term); - if (apr == null) continue; - const w = Number(d.advance); wSum += w; num += apr * w; - } - return wSum > 0 ? num / wSum : null; -} - // Tiny inline sparkline from a pool's idle/deployed split (a cheap, real "% deployed" // shape — no fake series). Shows a single filled bar proportion. function MiniBar({ pct }) { diff --git a/web/src/lib/sectors.js b/web/src/lib/sectors.js new file mode 100644 index 0000000..33ea0f8 --- /dev/null +++ b/web/src/lib/sectors.js @@ -0,0 +1,45 @@ +// Pure sector/APR derivation — the single source of truth for "pools grouped by +// DePIN category, with per-risk-class and max APR". Consumed by both Discover +// (floating coins) and Explore (blended-APR KPI / table). No React, no I/O. +import { CATEGORIES, CATEGORY_LABEL, CATEGORY_LOGO, RISK_CLASSES } from "./config.js"; +import { dealApr } from "./format.js"; + +// Advance-weighted trailing APR for one pool, over its financed deals +// (DealStatus 3 = Financed, 4 = Repaid). Returns a ratio (0.11 = 11%) or null. +export function poolTrailingApr(poolId, deals) { + const financed = deals.filter((d) => d.poolId === poolId && (d.status === 3 || d.status === 4)); + let wSum = 0, num = 0; + for (const d of financed) { + const apr = dealApr(d.advance, d.expected, d.term); + if (apr == null) continue; + const w = Number(d.advance); + wSum += w; + num += apr * w; + } + return wSum > 0 ? num / wSum : null; +} + +// Group pools into the five fixed categories. Always returns 5 entries (a sector +// with no pools still gets a coin). Classes are sorted A→B→C; maxApr is the max of +// the non-null class APRs (null when none are known). +export function groupBySector(pools, deals) { + return CATEGORIES.map((_, category) => { + const classes = pools + .filter((p) => p.category === category) + .map((p) => ({ + risk: RISK_CLASSES[p.class] ?? String(p.class), + poolId: p.poolId, + apr: poolTrailingApr(p.poolId, deals), + })) + .sort((a, b) => RISK_CLASSES.indexOf(a.risk) - RISK_CLASSES.indexOf(b.risk)); + + const knownAprs = classes.map((c) => c.apr).filter((a) => a != null); + return { + category, + label: CATEGORY_LABEL[category] ?? `CAT${category}`, + logo: CATEGORY_LOGO[category] ?? "/logos/hedera.svg", + classes, + maxApr: knownAprs.length ? Math.max(...knownAprs) : null, + }; + }); +} diff --git a/web/src/lib/sectors.test.js b/web/src/lib/sectors.test.js new file mode 100644 index 0000000..caf17c4 --- /dev/null +++ b/web/src/lib/sectors.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { poolTrailingApr, groupBySector } from "./sectors.js"; + +// 100 HBAR advance in 8-dp units; one full year term → year/term = 1. +const ADV = 100_00000000n; +const YEAR = 31_536_000n; // 365*24*60*60 +const deal = (poolId, expectedHbar, status = 4) => ({ + poolId, status, advance: ADV, expected: BigInt(expectedHbar) * 1_00000000n, term: YEAR, +}); + +describe("poolTrailingApr", () => { + it("returns null when a pool has no financed deals", () => { + expect(poolTrailingApr(0, [])).toBe(null); + }); + + it("ignores non-financed deals (status not 3 or 4)", () => { + // status 0 (Proposed) must not count. + expect(poolTrailingApr(0, [{ ...deal(0, 110), status: 0 }])).toBe(null); + }); + + it("computes the advance-weighted APR across financed deals", () => { + // Single deal: expected 110 vs advance 100 over a year → 0.10. + expect(poolTrailingApr(0, [deal(0, 110)])).toBeCloseTo(0.1, 6); + }); +}); + +describe("groupBySector", () => { + it("always returns one entry per category (5), even with no pools", () => { + const sectors = groupBySector([], []); + expect(sectors).toHaveLength(5); + expect(sectors[0]).toMatchObject({ category: 0, label: "GPU / Compute", logo: "/logos/gpu.png" }); + expect(sectors[0].classes).toEqual([]); + expect(sectors[0].maxApr).toBe(null); + }); + + it("groups pools by category, sorts classes A→B→C, and computes maxApr", () => { + const pools = [ + { poolId: 2, category: 0, class: 2 }, // GPU-C + { poolId: 0, category: 0, class: 0 }, // GPU-A + { poolId: 1, category: 0, class: 1 }, // GPU-B + ]; + const deals = [deal(0, 110), deal(1, 120), deal(2, 115)]; // 0.10, 0.20, 0.15 + const gpu = groupBySector(pools, deals)[0]; + expect(gpu.classes.map((c) => c.risk)).toEqual(["A", "B", "C"]); + expect(gpu.classes.map((c) => c.poolId)).toEqual([0, 1, 2]); + expect(gpu.maxApr).toBeCloseTo(0.2, 6); + }); + + it("keeps a class row with null apr but excludes it from maxApr", () => { + const pools = [ + { poolId: 0, category: 3, class: 0 }, // Energy-A, financed + { poolId: 1, category: 3, class: 1 }, // Energy-B, no deals → null apr + ]; + const energy = groupBySector(pools, [deal(0, 110)])[3]; + expect(energy.classes).toHaveLength(2); + expect(energy.classes[1].apr).toBe(null); + expect(energy.maxApr).toBeCloseTo(0.1, 6); + }); +}); From 0f0baee90ab02723e0b3381344ac27188e79366d Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:22:17 +0200 Subject: [PATCH 06/17] build(web): add motion (framer-motion successor) for coin animation --- web/package.json | 1 + web/pnpm-lock.yaml | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/web/package.json b/web/package.json index 73dff08..55a850d 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "test:watch": "vitest" }, "dependencies": { + "motion": "^12.40.0", "react": "^19.1.0", "react-dom": "^19.1.0", "viem": "^2.47.0" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7c9ffb2..469527f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + motion: + specifier: ^12.40.0 + version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: ^19.1.0 version: 19.2.7 @@ -755,6 +758,20 @@ packages: picomatch: optional: true + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + 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 + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -901,6 +918,26 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + 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 + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1040,6 +1077,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -1779,6 +1819,15 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + fsevents@2.3.3: optional: true @@ -1899,6 +1948,20 @@ snapshots: min-indent@1.0.1: {} + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + ms@2.1.3: {} nanoid@3.3.12: {} @@ -2040,6 +2103,8 @@ snapshots: dependencies: punycode: 2.3.1 + tslib@2.8.1: {} + undici@7.28.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): From fed912601ba80fe9d964b79cb393f1117416c7cf Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:22:56 +0200 Subject: [PATCH 07/17] =?UTF-8?q?feat(web):=20SectorCoin=20=E2=80=94=20flo?= =?UTF-8?q?ating=20sector=20coin=20with=20hover=20APR=20+=20class=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/discover/SectorCoin.jsx | 79 +++++++++++++++++++ .../components/discover/SectorCoin.test.jsx | 48 +++++++++++ 2 files changed, 127 insertions(+) create mode 100644 web/src/components/discover/SectorCoin.jsx create mode 100644 web/src/components/discover/SectorCoin.test.jsx diff --git a/web/src/components/discover/SectorCoin.jsx b/web/src/components/discover/SectorCoin.jsx new file mode 100644 index 0000000..6274d37 --- /dev/null +++ b/web/src/components/discover/SectorCoin.jsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { formatPercent } from "../../lib/format.js"; + +// One floating sector coin. Decorative at rest, interactive on hover/click: +// hover → capsule shows label + max APR +// click → menu of the sector's risk-class pools; a row opens it in Deposit +// The component is contract-agnostic: it takes a plain sector object (from +// lib/sectors.js groupBySector) plus the existing onOpenDeposit handler. +export default function SectorCoin({ sector, style, onOpenDeposit }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const hasPools = sector.classes.length > 0; + + useEffect(() => { + if (!open) return; + const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + const onKey = (e) => { if (e.key === "Escape") setOpen(false); }; + document.addEventListener("pointerdown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const aprLabel = sector.maxApr == null ? "—" : `max ${formatPercent(sector.maxApr)}`; + + return ( +
+ { if (hasPools) setOpen((o) => !o); }} + whileHover={{ scale: 1.08 }} + whileTap={{ scale: 0.96 }} + transition={{ type: "spring", stiffness: 300, damping: 20 }} + > + + + {sector.label} + {aprLabel} + + + + + {open && ( + + + {sector.classes.map((c) => ( +
  • + +
  • + ))} +
    + )} +
    +
    + ); +} diff --git a/web/src/components/discover/SectorCoin.test.jsx b/web/src/components/discover/SectorCoin.test.jsx new file mode 100644 index 0000000..45ad6cb --- /dev/null +++ b/web/src/components/discover/SectorCoin.test.jsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import SectorCoin from "./SectorCoin.jsx"; + +const sector = { + category: 0, + label: "GPU / Compute", + logo: "/logos/gpu.png", + classes: [ + { risk: "A", poolId: 0, apr: 0.1 }, + { risk: "B", poolId: 1, apr: 0.2 }, + { risk: "C", poolId: 2, apr: 0.15 }, + ], + maxApr: 0.2, +}; + +describe("SectorCoin", () => { + it("shows the label and max APR capsule", () => { + render( {}} />); + expect(screen.getByText("GPU / Compute")).toBeInTheDocument(); + expect(screen.getByText("max 20.00%")).toBeInTheDocument(); + }); + + it("opens a 3-class menu on click", async () => { + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole("button", { name: /GPU \/ Compute/ })); + expect(screen.getAllByRole("menuitem")).toHaveLength(3); + expect(screen.getByRole("menuitem", { name: /Class B/ })).toHaveTextContent("20.00%"); + }); + + it("routes the clicked class into Deposit and closes", async () => { + const user = userEvent.setup(); + const onOpenDeposit = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: /GPU \/ Compute/ })); + await user.click(screen.getByRole("menuitem", { name: /Class B/ })); + expect(onOpenDeposit).toHaveBeenCalledWith(1); + }); + + it("is disabled and shows — when the sector has no pools", () => { + const empty = { ...sector, classes: [], maxApr: null }; + render( {}} />); + expect(screen.getByRole("button", { name: /GPU \/ Compute/ })).toBeDisabled(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); From 0be554223eeabcba83599b3ddc427f057d09a742 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:26:38 +0200 Subject: [PATCH 08/17] feat(web): SectorCoins orbit layout + pointer parallax --- web/src/components/discover/SectorCoins.jsx | 45 +++++++++++++++++++ .../components/discover/SectorCoins.test.jsx | 17 +++++++ web/src/components/discover/layout.js | 13 ++++++ web/src/components/discover/layout.test.js | 15 +++++++ 4 files changed, 90 insertions(+) create mode 100644 web/src/components/discover/SectorCoins.jsx create mode 100644 web/src/components/discover/SectorCoins.test.jsx create mode 100644 web/src/components/discover/layout.js create mode 100644 web/src/components/discover/layout.test.js diff --git a/web/src/components/discover/SectorCoins.jsx b/web/src/components/discover/SectorCoins.jsx new file mode 100644 index 0000000..8a34428 --- /dev/null +++ b/web/src/components/discover/SectorCoins.jsx @@ -0,0 +1,45 @@ +import { useRef } from "react"; +import SectorCoin from "./SectorCoin.jsx"; +import { orbitPositions } from "./layout.js"; + +// Lays the coins out in an orbit and applies a subtle whole-field parallax tilt +// toward the pointer (rAF-batched, written to CSS vars; the actual tilt lives in +// discover.css and is disabled under prefers-reduced-motion). +export default function SectorCoins({ sectors, onOpenDeposit }) { + const ref = useRef(null); + const frame = useRef(0); + const positions = orbitPositions(sectors.length); + + const onPointerMove = (e) => { + const el = ref.current; + if (!el) return; + cancelAnimationFrame(frame.current); + frame.current = requestAnimationFrame(() => { + const r = el.getBoundingClientRect(); + if (!r.width || !r.height) return; + const dx = (e.clientX - (r.left + r.width / 2)) / r.width; // -0.5 … 0.5 + const dy = (e.clientY - (r.top + r.height / 2)) / r.height; + el.style.setProperty("--px", dx.toFixed(3)); + el.style.setProperty("--py", dy.toFixed(3)); + }); + }; + + return ( +
    + {sectors.map((sector, i) => ( + + ))} +
    + ); +} diff --git a/web/src/components/discover/SectorCoins.test.jsx b/web/src/components/discover/SectorCoins.test.jsx new file mode 100644 index 0000000..d273478 --- /dev/null +++ b/web/src/components/discover/SectorCoins.test.jsx @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import SectorCoins from "./SectorCoins.jsx"; + +const sectors = [ + { category: 0, label: "GPU / Compute", logo: "/logos/gpu.png", classes: [{ risk: "A", poolId: 0, apr: 0.1 }], maxApr: 0.1 }, + { category: 1, label: "Wireless", logo: "/logos/wireless.png", classes: [], maxApr: null }, +]; + +describe("SectorCoins", () => { + it("renders one coin button per sector", () => { + render( {}} />); + expect(screen.getAllByRole("button")).toHaveLength(2); + expect(screen.getByText("GPU / Compute")).toBeInTheDocument(); + expect(screen.getByText("Wireless")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/discover/layout.js b/web/src/components/discover/layout.js new file mode 100644 index 0000000..4963463 --- /dev/null +++ b/web/src/components/discover/layout.js @@ -0,0 +1,13 @@ +// Deterministic orbit placement for N coins, as percentage offsets inside the hero +// box. Coin 0 starts at top-center, going clockwise. Layout is derived from the +// count, so adding a sector rebalances the ring automatically. +export function orbitPositions(count, radius = 34) { + if (count <= 0) return []; + return Array.from({ length: count }, (_, i) => { + const angle = (-90 + (360 / count) * i) * (Math.PI / 180); + return { + x: 50 + radius * Math.cos(angle), + y: 50 + radius * Math.sin(angle), + }; + }); +} diff --git a/web/src/components/discover/layout.test.js b/web/src/components/discover/layout.test.js new file mode 100644 index 0000000..aad9069 --- /dev/null +++ b/web/src/components/discover/layout.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { orbitPositions } from "./layout.js"; + +describe("orbitPositions", () => { + it("returns one position per coin", () => { + expect(orbitPositions(5)).toHaveLength(5); + expect(orbitPositions(0)).toEqual([]); + }); + + it("places the first coin at top-center of the box", () => { + const [first] = orbitPositions(5, 34); + expect(first.x).toBeCloseTo(50, 5); + expect(first.y).toBeCloseTo(16, 5); // 50 - radius + }); +}); From db696e38097d8f7d2f2362e55a62eb07c8591194 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:27:17 +0200 Subject: [PATCH 09/17] feat(web): Discover hero view wiring sector data to coins --- web/src/components/Discover.jsx | 43 ++++++++++++++++++++++++ web/src/components/Discover.test.jsx | 29 ++++++++++++++++ web/src/components/discover/discover.css | 1 + 3 files changed, 73 insertions(+) create mode 100644 web/src/components/Discover.jsx create mode 100644 web/src/components/Discover.test.jsx create mode 100644 web/src/components/discover/discover.css diff --git a/web/src/components/Discover.jsx b/web/src/components/Discover.jsx new file mode 100644 index 0000000..2f81d22 --- /dev/null +++ b/web/src/components/Discover.jsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import SectorCoins from "./discover/SectorCoins.jsx"; +import { groupBySector } from "../lib/sectors.js"; +import "./discover/discover.css"; + +// Discover — the connected-app hero. Reads pools + deals (same source as Explore), +// groups them into the five DePIN sectors, and renders them as floating coins. +// Purely additive: it reuses the existing onOpenDeposit handler and touches no +// contract logic. +export default function Discover({ contracts, refreshKey, onOpenDeposit }) { + const [pools, setPools] = useState([]); + const [deals, setDeals] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!contracts) return; + let cancelled = false; + (async () => { + try { + const [list, dealList] = await Promise.all([contracts.getPools(), contracts.getDeals()]); + if (cancelled) return; + setPools(list); + setDeals(dealList); + } catch { /* keep last good data */ } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [contracts, refreshKey]); + + const sectors = groupBySector(pools, deals); + + return ( +
    +
    + Wafer +

    Hover a sector for its best yield · click to pick a pool

    +
    + + {loading &&
    Loading sectors…
    } +
    + ); +} diff --git a/web/src/components/Discover.test.jsx b/web/src/components/Discover.test.jsx new file mode 100644 index 0000000..fe40870 --- /dev/null +++ b/web/src/components/Discover.test.jsx @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Discover from "./Discover.jsx"; + +const ADV = 100_00000000n; +const YEAR = 31_536_000n; +const mockContracts = { + getPools: vi.fn().mockResolvedValue([ + { poolId: 0, category: 0, class: 0 }, + { poolId: 1, category: 0, class: 1 }, + ]), + getDeals: vi.fn().mockResolvedValue([ + { poolId: 0, status: 4, advance: ADV, expected: 110_00000000n, term: YEAR }, // 0.10 + { poolId: 1, status: 4, advance: ADV, expected: 120_00000000n, term: YEAR }, // 0.20 + ]), +}; + +describe("Discover", () => { + it("renders all five sector coins", () => { + render( {}} />); + // groupBySector always yields 5 sectors, so 5 coins render before data resolves. + expect(screen.getAllByRole("button")).toHaveLength(5); + }); + + it("shows the fetched max APR for a sector after load", async () => { + render( {}} />); + expect(await screen.findByText("max 20.00%")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/discover/discover.css b/web/src/components/discover/discover.css new file mode 100644 index 0000000..023de7b --- /dev/null +++ b/web/src/components/discover/discover.css @@ -0,0 +1 @@ +/* Discover hero + floating coin visuals — filled in Task 8. */ From b2a20b51c08fdabae615609f28e1a1c234b282b3 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:31:09 +0200 Subject: [PATCH 10/17] fix(web): cancel pending rAF on SectorCoins unmount --- web/src/components/discover/SectorCoins.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/components/discover/SectorCoins.jsx b/web/src/components/discover/SectorCoins.jsx index 8a34428..d896cf3 100644 --- a/web/src/components/discover/SectorCoins.jsx +++ b/web/src/components/discover/SectorCoins.jsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import SectorCoin from "./SectorCoin.jsx"; import { orbitPositions } from "./layout.js"; @@ -10,6 +10,8 @@ export default function SectorCoins({ sectors, onOpenDeposit }) { const frame = useRef(0); const positions = orbitPositions(sectors.length); + useEffect(() => () => cancelAnimationFrame(frame.current), []); + const onPointerMove = (e) => { const el = ref.current; if (!el) return; From b3afacdbb279abd1cbc40f8638d8f2a2bf03ebba Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 09:31:44 +0200 Subject: [PATCH 11/17] feat(web): route Discover as connected landing + nav item --- web/src/App.jsx | 14 +++++++++++--- web/src/components/TopNav.jsx | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/App.jsx b/web/src/App.jsx index 11614bb..02564c2 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -6,6 +6,7 @@ import HowItWorks from "./components/HowItWorks.jsx"; import StatusBar from "./components/StatusBar.jsx"; import DepositCard from "./components/DepositCard.jsx"; import Explore from "./components/Explore.jsx"; +import Discover from "./components/Discover.jsx"; import Portfolio from "./components/Portfolio.jsx"; import OperatorPortal from "./components/OperatorPortal.jsx"; import Admin from "./components/Admin.jsx"; @@ -16,7 +17,7 @@ import { useContracts } from "./hooks/useContracts.js"; import { formatError } from "./lib/errors.js"; // Investor-mode tabs only — anything else is Admin-gated. -const INVESTOR_TABS = new Set(["home", "deposit", "explore", "dashboard", "operator", "activity", "queue"]); +const INVESTOR_TABS = new Set(["home", "discover", "deposit", "explore", "dashboard", "operator", "activity", "queue"]); export default function App() { const { account, walletClient, publicClient, connecting, connect, disconnect, wrongNetwork, switchNetwork } = useWallet(); @@ -133,7 +134,7 @@ export default function App() { useEffect(() => { if (account) { setWalletModalOpen(false); - setTab((t) => (t === "home" ? "deposit" : t)); + setTab((t) => (t === "home" ? "discover" : t)); } }, [account]); @@ -145,7 +146,7 @@ export default function App() { }, [disconnect]); const goApp = useCallback(() => { - if (account) setTab("deposit"); + if (account) setTab("discover"); else openWalletModal(); }, [account, openWalletModal]); @@ -204,6 +205,13 @@ export default function App() {
    + {tab === "discover" && ( + + )} {tab === "deposit" && ( Date: Wed, 17 Jun 2026 09:32:22 +0200 Subject: [PATCH 12/17] style(web): Discover hero + floating coin visuals (reduced-motion safe) --- web/src/components/discover/discover.css | 168 ++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/web/src/components/discover/discover.css b/web/src/components/discover/discover.css index 023de7b..7e93b24 100644 --- a/web/src/components/discover/discover.css +++ b/web/src/components/discover/discover.css @@ -1 +1,167 @@ -/* Discover hero + floating coin visuals — filled in Task 8. */ +/* Discover hero — floating DePIN sector coins. Decorative layer; the coins are the + only interactive elements. Palette tracks the app: deep navy bg + amber accent. */ + +.discover-hero { + position: relative; + width: 100%; + min-height: clamp(420px, 70vh, 760px); + overflow: visible; +} + +/* Centered wordmark + tagline, behind the coins. */ +.discover-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + text-align: center; + pointer-events: none; + z-index: 1; +} +.discover-wordmark { height: 40px; width: auto; opacity: 0.95; } +.discover-tagline { + margin: 0; + font-size: 0.85rem; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.55); +} +.discover-loading { + position: absolute; + bottom: 1rem; left: 50%; + transform: translateX(-50%); + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.45); + z-index: 1; +} + +/* The coin field: pointer-events:none so only coins capture input; a subtle 3D + parallax tilt is driven by --px/--py written from SectorCoins. */ +.coins-field { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + transform: perspective(1100px) + rotateY(calc(var(--px, 0) * 6deg)) + rotateX(calc(var(--py, 0) * -6deg)); + transform-style: preserve-3d; + transition: transform 0.25s ease-out; +} + +/* A single coin slot. The float lives here so it composes with the inner button's + hover-scale (set by motion) without the two transforms fighting. */ +.coin { + position: absolute; + pointer-events: auto; + animation: coin-float var(--float-dur, 8s) ease-in-out infinite; + animation-delay: var(--float-delay, 0s); + will-change: transform; +} +@keyframes coin-float { + 0%, 100% { transform: translate(-50%, -50%) rotate(var(--tilt, 0deg)); } + 50% { transform: translate(-50%, calc(-50% - 12px)) rotate(calc(var(--tilt, 0deg) * -1)); } +} + +/* The coin button itself. */ +.coin-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 0; + background: none; + border: 0; + cursor: pointer; + color: #fff; +} +.coin-btn:disabled { cursor: default; opacity: 0.5; } +.coin-btn:focus-visible { outline: 2px solid #f59e0b; outline-offset: 6px; border-radius: 12px; } + +.coin-img { + width: clamp(56px, 7vw, 84px); + height: auto; + filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.5)); + transition: filter 0.18s ease; +} +.coin-btn:hover:not(:disabled) .coin-img { + filter: drop-shadow(0 10px 26px rgba(245, 158, 11, 0.45)); +} + +/* Hover capsule (label + max APR). Hidden until hover/focus. */ +.coin-cap { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: rgba(10, 12, 18, 0.85); + border: 1px solid rgba(245, 158, 11, 0.35); + backdrop-filter: blur(6px); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.18s ease, transform 0.18s ease; + white-space: nowrap; +} +.coin-btn:hover:not(:disabled) .coin-cap, +.coin-btn:focus-visible .coin-cap, +.coin-btn[aria-expanded="true"] .coin-cap { + opacity: 1; + transform: translateY(0); +} +.coin-cap-label { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.01em; } +.coin-cap-apr { font-size: 0.72rem; color: #f59e0b; font-variant-numeric: tabular-nums; } + +/* Click-expanded menu of the sector's risk-class pools. */ +.coin-menu { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + margin: 0; + padding: 0.35rem; + list-style: none; + min-width: 168px; + border-radius: 12px; + background: rgba(10, 12, 18, 0.96); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5); + z-index: 6; +} +.coin-menu-title { + padding: 0.3rem 0.6rem 0.4rem; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.45); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + margin-bottom: 0.25rem; +} +.coin-menu-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + width: 100%; + padding: 0.45rem 0.6rem; + background: none; + border: 0; + border-radius: 8px; + cursor: pointer; + color: #fff; + font-size: 0.8rem; +} +.coin-menu-row:hover { background: rgba(245, 158, 11, 0.14); } +.coin-menu-class { color: rgba(255, 255, 255, 0.8); } +.coin-menu-apr { color: #f59e0b; font-variant-numeric: tabular-nums; } + +@media (prefers-reduced-motion: reduce) { + .coin { + animation: none; + transform: translate(-50%, -50%) rotate(var(--tilt, 0deg)); + } + .coins-field { transform: none; transition: none; } +} From 9886011d2d514a42f5aea56df1fe9d92c770da03 Mon Sep 17 00:00:00 2001 From: Sofiane Date: Wed, 17 Jun 2026 19:23:26 +0200 Subject: [PATCH 13/17] perf(web): lazy-load Discover route to split motion bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static Discover import with React.lazy() + Suspense so that framer-motion/motion (~132 KB) lands in a separate async chunk instead of the main bundle. Main bundle: 760 → 628 KB (−17%); Discover chunk: 132 KB. --- web/src/App.jsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/web/src/App.jsx b/web/src/App.jsx index 02564c2..9656770 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; +import React, { useState, useCallback, useEffect, useRef, lazy, Suspense } from "react"; import TopNav from "./components/TopNav.jsx"; import Footer from "./components/Footer.jsx"; import Hero from "./components/Hero.jsx"; @@ -6,7 +6,6 @@ import HowItWorks from "./components/HowItWorks.jsx"; import StatusBar from "./components/StatusBar.jsx"; import DepositCard from "./components/DepositCard.jsx"; import Explore from "./components/Explore.jsx"; -import Discover from "./components/Discover.jsx"; import Portfolio from "./components/Portfolio.jsx"; import OperatorPortal from "./components/OperatorPortal.jsx"; import Admin from "./components/Admin.jsx"; @@ -16,6 +15,8 @@ import { useWallet } from "./hooks/useWallet.js"; import { useContracts } from "./hooks/useContracts.js"; import { formatError } from "./lib/errors.js"; +const Discover = lazy(() => import("./components/Discover.jsx")); + // Investor-mode tabs only — anything else is Admin-gated. const INVESTOR_TABS = new Set(["home", "discover", "deposit", "explore", "dashboard", "operator", "activity", "queue"]); @@ -206,11 +207,13 @@ export default function App() {
    {tab === "discover" && ( - + Loading…
    }> + + )} {tab === "deposit" && ( Date: Wed, 17 Jun 2026 19:23:35 +0200 Subject: [PATCH 14/17] test(web): App routing integration test for Discover wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add App.test.jsx covering the routing paths that unit tests don't exercise: - connected app lands on Discover (lazy-loads) with Discover nav item active - GPU coin → class B row → onOpenDeposit(1) → setTab("deposit"), verified by Deposit nav button gaining aria-current="page" Mocks useWallet (returns connected account) and useContracts (returns configured contracts with getPools/getDeals/getRoles/getHbarBalance + DepositCard helpers). Adds a ResizeObserver stub for jsdom compatibility. --- web/src/App.test.jsx | 98 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 web/src/App.test.jsx diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx new file mode 100644 index 0000000..28e3dbb --- /dev/null +++ b/web/src/App.test.jsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import App from "./App.jsx"; + +// jsdom does not ship ResizeObserver; Hero's VideoPixelGrid needs it on mount. +// Stub it so the brief Hero flash before tab→discover doesn't crash the tests. +if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +// --------------------------------------------------------------------------- +// Wallet + contracts mocks — keep them outside vi.mock() factories so they +// can be imported in tests for assertions if needed. +// --------------------------------------------------------------------------- + +vi.mock("./hooks/useWallet.js", () => ({ + useWallet: () => ({ + account: "0x00000000000000000000000000000000000000a1", + walletClient: {}, + publicClient: {}, + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), + wrongNetwork: false, + switchNetwork: vi.fn(), + }), +})); + +const ADV = 100_00000000n; +const YEAR = 31_536_000n; + +vi.mock("./hooks/useContracts.js", () => ({ + useContracts: () => ({ + configured: true, + getPools: vi.fn().mockResolvedValue([ + { poolId: 0, category: 0, class: 0 }, + { poolId: 1, category: 0, class: 1 }, + ]), + getDeals: vi.fn().mockResolvedValue([ + { poolId: 0, status: 4, advance: ADV, expected: 110_00000000n, term: YEAR }, + { poolId: 1, status: 4, advance: ADV, expected: 120_00000000n, term: YEAR }, + ]), + getRoles: vi.fn().mockResolvedValue({ isOwner: false, isOperator: false }), + getHbarBalance: vi.fn().mockResolvedValue(0n), + // Methods called by DepositCard when it mounts on the Deposit tab. + getShareBalance: vi.fn().mockResolvedValue(null), + getKycStatus: vi.fn().mockResolvedValue({ associated: false, kycGranted: false }), + getMaxRedeem: vi.fn().mockResolvedValue(0n), + getNavPerShare: vi.fn().mockResolvedValue(1_00000000n), + }), +})); + +describe("App routing integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lands on Discover and shows the Discover nav item active", async () => { + render(); + + // Wait for the lazy Discover component to load and render its tagline. + expect(await screen.findByText(/Hover a sector/i)).toBeInTheDocument(); + + // The Discover nav button should be marked active. + const discoverBtn = screen.getByRole("button", { name: /Discover/i }); + expect(discoverBtn).toHaveAttribute("aria-current", "page"); + }); + + it("clicking a sector coin class row routes to Deposit (Deposit nav becomes active)", async () => { + const user = userEvent.setup(); + render(); + + // Wait for the lazy Discover component to finish loading. + await screen.findByText(/Hover a sector/i); + + // The GPU / Compute coin (category 0, has 2 pools from the mock) should be present. + // SectorCoin renders a button whose accessible name includes the sector label. + const gpuCoin = await screen.findByRole("button", { name: /GPU \/ Compute/i }); + await user.click(gpuCoin); + + // The class menu should now be open (two class rows: A and B). + const classB = await screen.findByRole("menuitem", { name: /Class B/i }); + await user.click(classB); + + // After clicking, onOpenDeposit(1) is called → setTab("deposit"). + // Assert routing by checking the Deposit button in the PRIMARY NAV is now active. + // Use within() on the