diff --git a/.agents/skills/react-useeffect/README.md b/.agents/skills/react-useeffect/README.md new file mode 100644 index 00000000..490ad4ba --- /dev/null +++ b/.agents/skills/react-useeffect/README.md @@ -0,0 +1,320 @@ +# React useEffect Best Practices + +A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns. + +## Purpose + +Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means. + +This skill helps you: +- Identify when you truly need an Effect vs. when you don't +- Recognize common anti-patterns and their fixes +- Apply better alternatives like `useMemo`, `key` prop, and event handlers +- Write Effects that are clean, maintainable, and free from race conditions + +## When to Use This Skill + +Use this skill when you're: +- Writing or reviewing `useEffect` code +- Using `useState` to store derived values +- Implementing data fetching or subscriptions +- Synchronizing state between components +- Facing bugs with stale data or race conditions +- Wondering if your Effect is necessary + +**Trigger phrases:** +- "Should I use useEffect for this?" +- "How do I fix this useEffect?" +- "My Effect is causing too many re-renders" +- "Data fetching with useEffect" +- "Reset state when props change" +- "Derived state from props" + +## How It Works + +This skill provides guidance through three key resources: + +1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns +2. **Decision Tree** - Visual flowchart to determine the right approach +3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes +4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects + +The skill teaches you to ask the right questions: +- Is there an external system involved? +- Am I responding to a user event or component appearance? +- Can this value be calculated during render? +- Do I need to reset state when a prop changes? + +## Key Features + +### 1. Quick Reference Guide + +Visual table showing the DO/DON'T for common scenarios: +- Derived state from props/state +- Expensive calculations +- Resetting state on prop change +- User event responses +- Notifying parent components +- Data fetching + +### 2. Decision Tree + +Clear flowchart that guides you from "Need to respond to something?" to the correct solution: +- User interaction → Event handler +- Component appeared → Effect (for external sync/analytics) +- Derived value needed → Calculate during render (+ useMemo if expensive) +- Reset state on prop change → Key prop + +### 3. Anti-Pattern Recognition + +Detailed examples of 9 common mistakes: +1. Redundant state for derived values +2. Filtering/transforming data in Effect +3. Resetting state on prop change +4. Event-specific logic in Effect +5. Chains of Effects +6. Notifying parent via Effect +7. Passing data up to parent +8. Fetching without cleanup (race conditions) +9. App initialization in Effect + +Each anti-pattern includes: +- Bad example with explanation +- Good example with fix +- Why the anti-pattern is problematic + +### 4. Better Alternatives + +8 proven patterns to replace unnecessary Effects: +1. Calculate during render for derived state +2. `useMemo` for expensive calculations +3. `key` prop to reset state +4. Store ID instead of object for stable references +5. Event handlers for user actions +6. `useSyncExternalStore` for external stores +7. Lifting state up for shared state +8. Custom hooks for data fetching with cleanup + +## Usage Examples + +### Example 1: Derived State + +**Bad - Unnecessary Effect:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} +``` + +**Good - Calculate during render:** +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +### Example 2: Resetting State + +**Bad - Effect to reset:** +```tsx +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} +``` + +**Good - Key prop:** +```tsx +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +### Example 3: Data Fetching with Cleanup + +**Bad - Race condition:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} +``` + +**Good - Cleanup flag:** +```tsx +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +### Example 4: Event Handler Instead of Effect + +**Bad - Effect watching state:** +```tsx +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} +``` + +**Good - Handle in event:** +```tsx +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +## When You DO Need Effects + +Effects are appropriate for: + +- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code +- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`) +- **Analytics/logging** - Code that needs to run because the component displayed +- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism) + +## When You DON'T Need Effects + +Avoid Effects for: + +1. **Transforming data for rendering** - Calculate at the top level instead +2. **Handling user events** - Use event handlers where you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler +5. **Notifying parent components** - Call the callback in the same event handler +6. **Resetting state** - Use the `key` prop to create a fresh component instance + +## Best Practices + +### 1. Start Without an Effect + +Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect. + +### 2. Prefer Derived State + +If you can calculate a value from props or state, don't store it in state with an Effect updating it. + +### 3. Use the Right Tool + +- Expensive calculation → `useMemo` +- User interaction → Event handler +- Reset on prop change → `key` prop +- External subscription → `useSyncExternalStore` +- Shared state → Lift state up + +### 4. Always Clean Up + +If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions. + +### 5. Avoid Effect Chains + +Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler). + +### 6. Test in Strict Mode + +React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup. + +### 7. Consider Framework Solutions + +For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects. + +## Reference Files + +This skill includes three detailed reference documents: + +1. **SKILL.md** - Quick reference table and decision tree +2. **anti-patterns.md** - 9 common mistakes with detailed explanations +3. **alternatives.md** - 8 better alternatives with code examples + +## Common Pitfalls + +### Multiple Re-renders + +**Symptom:** Component re-renders many times in quick succession. + +**Cause:** Effect that sets state based on state it depends on, creating a loop. + +**Fix:** Calculate the final value in an event handler or during render. + +### Stale Data + +**Symptom:** UI shows outdated values briefly before updating. + +**Cause:** Using Effect to update derived state causes an extra render pass. + +**Fix:** Calculate derived values during render instead of in state. + +### Race Conditions + +**Symptom:** Fast typing shows results for old queries after new ones. + +**Cause:** Missing cleanup in data fetching Effect. + +**Fix:** Use cleanup flag (`ignore` variable) or AbortController. + +### Runs Twice in Development + +**Symptom:** Effect runs twice on component mount in development. + +**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs. + +**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard. + +## Resources + +This skill is based on: +- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects) +- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) + +## Summary + +The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect. + +Before writing `useEffect`, ask yourself: +1. Is this responding to a user interaction? → Use event handler +2. Is this a value I can calculate from props/state? → Calculate during render +3. Is this resetting state when a prop changes? → Use key prop +4. Is this synchronizing with an external system? → Use Effect with cleanup + +Follow these patterns, and your React code will be more maintainable, performant, and bug-free. diff --git a/.agents/skills/react-useeffect/SKILL.md b/.agents/skills/react-useeffect/SKILL.md new file mode 100644 index 00000000..d7c6ffb2 --- /dev/null +++ b/.agents/skills/react-useeffect/SKILL.md @@ -0,0 +1,53 @@ +--- +name: react-useeffect +description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives. +--- + +# You Might Not Need an Effect + +Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect. + +## Quick Reference + +| Situation | DON'T | DO | +|-----------|-------|-----| +| Derived state from props/state | `useState` + `useEffect` | Calculate during render | +| Expensive calculations | `useEffect` to cache | `useMemo` | +| Reset state on prop change | `useEffect` with `setState` | `key` prop | +| User event responses | `useEffect` watching state | Event handler directly | +| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler | +| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework | + +## When You DO Need Effects + +- Synchronizing with **external systems** (non-React widgets, browser APIs) +- **Subscriptions** to external stores (use `useSyncExternalStore` when possible) +- **Analytics/logging** that runs because component displayed +- **Data fetching** with proper cleanup (or use framework's built-in mechanism) + +## When You DON'T Need Effects + +1. **Transforming data for rendering** - Calculate at top level, re-runs automatically +2. **Handling user events** - Use event handlers, you know exactly what happened +3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` +4. **Chaining state updates** - Calculate all next state in the event handler + +## Decision Tree + +``` +Need to respond to something? +├── User interaction (click, submit, drag)? +│ └── Use EVENT HANDLER +├── Component appeared on screen? +│ └── Use EFFECT (external sync, analytics) +├── Props/state changed and need derived value? +│ └── CALCULATE DURING RENDER +│ └── Expensive? Use useMemo +└── Need to reset state when prop changes? + └── Use KEY PROP on component +``` + +## Detailed Guidance + +- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes +- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore diff --git a/.agents/skills/react-useeffect/alternatives.md b/.agents/skills/react-useeffect/alternatives.md new file mode 100644 index 00000000..791744ab --- /dev/null +++ b/.agents/skills/react-useeffect/alternatives.md @@ -0,0 +1,258 @@ +# Better Alternatives to useEffect + +## 1. Calculate During Render (Derived State) + +For values derived from props or state, just compute them: + +```tsx +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + + // Runs every render - that's fine and intentional + const fullName = firstName + ' ' + lastName; + const isValid = firstName.length > 0 && lastName.length > 0; +} +``` + +**When to use**: The value can be computed from existing props/state. + +--- + +## 2. useMemo for Expensive Calculations + +When computation is expensive, memoize it: + +```tsx +import { useMemo } from 'react'; + +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +**How to know if it's expensive**: +```tsx +console.time('filter'); +const visibleTodos = getFilteredTodos(todos, filter); +console.timeEnd('filter'); +// If > 1ms, consider memoizing +``` + +**Note**: React Compiler can auto-memoize, reducing manual useMemo needs. + +--- + +## 3. Key Prop to Reset State + +To reset ALL state when a prop changes, use key: + +```tsx +// Parent passes userId as key +function ProfilePage({ userId }) { + return ( + + ); +} + +function Profile({ userId }) { + // All state here resets when userId changes + const [comment, setComment] = useState(''); + const [likes, setLikes] = useState([]); +} +``` + +**When to use**: You want a "fresh start" when an identity prop changes. + +--- + +## 4. Store ID Instead of Object + +To preserve selection when list changes: + +```tsx +// BAD: Storing object that needs Effect to "adjust" +function List({ items }) { + const [selection, setSelection] = useState(null); + + useEffect(() => { + setSelection(null); // Reset when items change + }, [items]); +} + +// GOOD: Store ID, derive object +function List({ items }) { + const [selectedId, setSelectedId] = useState(null); + + // Derived - no Effect needed + const selection = items.find(item => item.id === selectedId) ?? null; +} +``` + +**Benefit**: If item with selectedId exists in new list, selection preserved. + +--- + +## 5. Event Handlers for User Actions + +User clicks/submits/drags should be handled in event handlers, not Effects: + +```tsx +// Event handler knows exactly what happened +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + analytics.track('product_added', { id: product.id }); + } + + function handleCheckoutClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + navigateTo('/checkout'); + } +} +``` + +**Shared logic**: Extract a function, call from both handlers: + +```tsx +function buyProduct() { + addToCart(product); + showNotification(`Added ${product.name}!`); +} + +function handleBuyClick() { buyProduct(); } +function handleCheckoutClick() { buyProduct(); navigateTo('/checkout'); } +``` + +--- + +## 6. useSyncExternalStore for External Stores + +For subscribing to external data (browser APIs, third-party stores): + +```tsx +// Instead of manual Effect subscription +function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + + useEffect(() => { + function update() { setIsOnline(navigator.onLine); } + window.addEventListener('online', update); + window.addEventListener('offline', update); + return () => { + window.removeEventListener('online', update); + window.removeEventListener('offline', update); + }; + }, []); + + return isOnline; +} + +// Use purpose-built hook +import { useSyncExternalStore } from 'react'; + +function subscribe(callback) { + window.addEventListener('online', callback); + window.addEventListener('offline', callback); + return () => { + window.removeEventListener('online', callback); + window.removeEventListener('offline', callback); + }; +} + +function useOnlineStatus() { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, // Client value + () => true // Server value (SSR) + ); +} +``` + +--- + +## 7. Lifting State Up + +When two components need synchronized state, lift it to common ancestor: + +```tsx +// Instead of syncing via Effects between siblings +function Parent() { + const [value, setValue] = useState(''); + + return ( + <> + + + + ); +} +``` + +--- + +## 8. Custom Hooks for Data Fetching + +Extract fetch logic with proper cleanup: + +```tsx +function useData(url) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let ignore = false; + setLoading(true); + + fetch(url) + .then(res => res.json()) + .then(json => { + if (!ignore) { + setData(json); + setError(null); + } + }) + .catch(err => { + if (!ignore) setError(err); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + + return () => { ignore = true; }; + }, [url]); + + return { data, error, loading }; +} + +// Usage +function SearchResults({ query }) { + const { data, error, loading } = useData(`/api/search?q=${query}`); +} +``` + +**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.) + +--- + +## Summary: When to Use What + +| Need | Solution | +|------|----------| +| Value from props/state | Calculate during render | +| Expensive calculation | `useMemo` | +| Reset all state on prop change | `key` prop | +| Respond to user action | Event handler | +| Sync with external system | `useEffect` with cleanup | +| Subscribe to external store | `useSyncExternalStore` | +| Share state between components | Lift state up | +| Fetch data | Custom hook with cleanup / framework | diff --git a/.agents/skills/react-useeffect/anti-patterns.md b/.agents/skills/react-useeffect/anti-patterns.md new file mode 100644 index 00000000..d35151fd --- /dev/null +++ b/.agents/skills/react-useeffect/anti-patterns.md @@ -0,0 +1,290 @@ +# useEffect Anti-Patterns + +## 1. Redundant State for Derived Values + +```tsx +// BAD: Extra state + Effect for derived value +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const [fullName, setFullName] = useState(''); + + useEffect(() => { + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); +} + +// GOOD: Calculate during rendering +function Form() { + const [firstName, setFirstName] = useState('Taylor'); + const [lastName, setLastName] = useState('Swift'); + const fullName = firstName + ' ' + lastName; // Just compute it +} +``` + +**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value. + +--- + +## 2. Filtering/Transforming Data in Effect + +```tsx +// BAD: Effect to filter list +function TodoList({ todos, filter }) { + const [visibleTodos, setVisibleTodos] = useState([]); + + useEffect(() => { + setVisibleTodos(getFilteredTodos(todos, filter)); + }, [todos, filter]); +} + +// GOOD: Filter during render (memoize if expensive) +function TodoList({ todos, filter }) { + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); +} +``` + +--- + +## 3. Resetting State on Prop Change + +```tsx +// BAD: Effect to reset state +function ProfilePage({ userId }) { + const [comment, setComment] = useState(''); + + useEffect(() => { + setComment(''); + }, [userId]); +} + +// GOOD: Use key prop +function ProfilePage({ userId }) { + return ; +} + +function Profile({ userId }) { + const [comment, setComment] = useState(''); // Resets automatically +} +``` + +**Why key works**: React treats components with different keys as different components, recreating state. + +--- + +## 4. Event-Specific Logic in Effect + +```tsx +// BAD: Effect for button click result +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name}!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } +} + +// GOOD: Handle in event handler +function ProductPage({ product, addToCart }) { + function handleBuyClick() { + addToCart(product); + showNotification(`Added ${product.name}!`); + } +} +``` + +**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly. + +--- + +## 5. Chains of Effects + +```tsx +// BAD: Effects triggering each other +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const [isGameOver, setIsGameOver] = useState(false); + + useEffect(() => { + if (card?.gold) setGoldCardCount(c => c + 1); + }, [card]); + + useEffect(() => { + if (goldCardCount > 3) { + setRound(r => r + 1); + setGoldCardCount(0); + } + }, [goldCardCount]); + + useEffect(() => { + if (round > 5) setIsGameOver(true); + }, [round]); +} + +// GOOD: Calculate in event handler +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const isGameOver = round > 5; // Derived! + + function handlePlaceCard(nextCard) { + if (isGameOver) throw Error('Game ended'); + + setCard(nextCard); + if (nextCard.gold) { + if (goldCardCount < 3) { + setGoldCardCount(goldCardCount + 1); + } else { + setGoldCardCount(0); + setRound(round + 1); + if (round === 5) alert('Good game!'); + } + } + } +} +``` + +**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay. + +--- + +## 6. Notifying Parent via Effect + +```tsx +// BAD: Effect to notify parent +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + useEffect(() => { + onChange(isOn); + }, [isOn, onChange]); + + function handleClick() { + setIsOn(!isOn); + } +} + +// GOOD: Notify in same event +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + function updateToggle(nextIsOn) { + setIsOn(nextIsOn); + onChange(nextIsOn); // Same event, batched render + } + + function handleClick() { + updateToggle(!isOn); + } +} + +// BEST: Fully controlled component +function Toggle({ isOn, onChange }) { + function handleClick() { + onChange(!isOn); + } +} +``` + +--- + +## 7. Passing Data Up to Parent + +```tsx +// BAD: Child fetches, passes up via Effect +function Parent() { + const [data, setData] = useState(null); + return ; +} + +function Child({ onFetched }) { + const data = useSomeAPI(); + + useEffect(() => { + if (data) onFetched(data); + }, [onFetched, data]); +} + +// GOOD: Parent fetches, passes down +function Parent() { + const data = useSomeAPI(); + return ; +} +``` + +**Why**: Data should flow down. Upward flow via Effects makes debugging hard. + +--- + +## 8. Fetching Without Cleanup (Race Condition) + +```tsx +// BAD: No cleanup - race condition +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + fetchResults(query).then(json => { + setResults(json); // "hello" response may arrive after "hell" + }); + }, [query]); +} + +// GOOD: Cleanup ignores stale responses +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + let ignore = false; + + fetchResults(query).then(json => { + if (!ignore) setResults(json); + }); + + return () => { ignore = true; }; + }, [query]); +} +``` + +--- + +## 9. App Initialization in Effect + +```tsx +// BAD: Runs twice in dev, may break auth +function App() { + useEffect(() => { + loadDataFromLocalStorage(); + checkAuthToken(); // May invalidate token on second call! + }, []); +} + +// GOOD: Module-level guard +let didInit = false; + +function App() { + useEffect(() => { + if (!didInit) { + didInit = true; + loadDataFromLocalStorage(); + checkAuthToken(); + } + }, []); +} + +// ALSO GOOD: Module-level execution +if (typeof window !== 'undefined') { + checkAuthToken(); + loadDataFromLocalStorage(); +} +``` diff --git a/.claude/skills/figma.md b/.claude/skills/figma.md deleted file mode 100644 index 0f5380f0..00000000 --- a/.claude/skills/figma.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: figma -description: Convert Figma designs to code using project design tokens instead of raw hex colors -user_invocable: true ---- - -Convert Figma designs into production-ready React components using the project's design token system. Replaces all raw hex colors with semantic Tailwind token classes. - -## Process - -### 1. Load Design Tokens - -Read `design-tokens.md` (project root) to build a hex-to-token lookup map. Each hex value maps to a token name and its context (light or dark theme): - -``` -#0D0F11 → surface-base (dark) -#F1F3F4 → surface-base (light) -#2B2E48 → text-primary (light) -#E6E9EF → text-primary (dark) -#626B75 → text-secondary (light) -#A9B0BC → text-secondary (dark) -#2563EB → action-primary (light) -#4F7DFF → action-primary (dark) -... -``` - -Also read `src/styles.css` for the full CSS variable definitions including any tokens not in the doc. - -### 2. Extract Figma Data - -For each Figma URL provided, extract `fileKey` and `nodeId` from the URL format: -- `https://figma.com/design/:fileKey/:fileName?node-id=:nodeId` -- Convert `node-id=1-2` to `nodeId=1:2` - -Then call these MCP tools in parallel: -- `get_screenshot` — visual reference of the design -- `get_design_context` — generated code with raw hex values -- `get_variable_defs` — Figma variable names and their resolved hex values - -### 3. Build Color Audit Table - -For every hex color found in the design context output, produce a table: - -| Hex | Token | Tailwind Class | Theme | Usage | -|-----|-------|---------------|-------|-------| -| `#0D0F11` | `surface-base` | `bg-surface-base` | dark | background | -| `#E6E9EF` | `text-primary` | `text-text-primary` | dark | heading text | -| `#FF00FF` | **UNKNOWN** | — | — | accent border | - -Cross-reference Figma variable names from `get_variable_defs` with token names to improve matching accuracy. - -### 4. Generate Component Code - -Convert the Figma output into a React component following project conventions: - -**Token replacement rules:** -- Fill/background hex → `bg-{token}` (e.g., `bg-surface-base`, `bg-action-primary`) -- Text color hex → `text-{token}` (e.g., `text-text-primary`, `text-market-up-primary`) -- Border color hex → `border-{token}` (e.g., `border-border`, `border-action-primary`) -- Ring/focus hex → `ring-{token}` -- For text tokens, the Tailwind class is `text-text-{name}` (double "text" is correct) - -**Font size replacement rules (no arbitrary sizes):** -- 8px → `text-5xs` -- 9px → `text-4xs` -- 10px → `text-3xs` -- 11px → `text-2xs` -- 12px → `text-xs` -- 13px → `text-nav` -- 14px → `text-sm` -- 16px → `text-base` - -**Code style (from `.claude/rules/code-style.md`):** -- Use `cn()` from `@/lib/cn` for conditional classes -- Use `interface Props` for prop types -- Prefer function declarations over arrows -- Icons from `@phosphor-icons/react` with `Icon` suffix -- No comments, no console.log -- React 19 — no manual useMemo/useCallback for performance - -**Token rules (from `.claude/rules/design-tokens.md`):** -- Never use hardcoded hex like `text-[#2b2e48]` or `bg-[#f1f3f4]` -- Never use arbitrary font sizes like `text-[10px]` -- Use `market-*` tokens for trading data (PnL, prices, percentages) -- Use `status-*` tokens for system feedback (errors, warnings, success) -- `bg-surface` doesn't exist — use `bg-surface-base`, `bg-surface-execution`, etc. - -### 5. Report Mismatches - -After generating code, list any hex values that could not be mapped to a known token: - -``` -UNKNOWN COLORS: -- #FF00FF — used as accent border; closest match: action-primary (#2563EB) -- #333333 — used as text color; closest match: text-primary (light: #2B2E48) -``` - -For each unknown, suggest the closest semantic token based on the color's purpose and value. - -## Instructions - -1. Read `design-tokens.md` to build the hex → token lookup map -2. For each Figma URL argument: - a. Parse `fileKey` and `nodeId` from the URL - b. Call `get_screenshot`, `get_design_context`, and `get_variable_defs` in parallel - c. Review the screenshot to understand the design intent - d. Build the color audit table from the design context hexes - e. Generate the component code with all hex values replaced by token classes -3. Present the color audit table -4. Present the generated component code -5. List any unknown/unmapped colors with suggested closest tokens -6. If multiple URLs were provided, repeat steps 2-5 for each diff --git a/.claude/skills/hyperliquid-api b/.claude/skills/hyperliquid-api new file mode 120000 index 00000000..e9f2405f --- /dev/null +++ b/.claude/skills/hyperliquid-api @@ -0,0 +1 @@ +../../packages/hyperliquid-api \ No newline at end of file diff --git a/.claude/skills/icons.md b/.claude/skills/icons.md deleted file mode 100644 index c8729fde..00000000 --- a/.claude/skills/icons.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: icons -description: Find Phosphor icons for UI components -user_invocable: true ---- - -Use `@phosphor-icons/react` for all icons. Do NOT use `lucide-react`. - -## Usage - -All icons have an `Icon` suffix (e.g., `BellIcon`, `GearIcon`): - -```tsx -import { BellIcon, GearIcon } from "@phosphor-icons/react"; - - - -``` - -## Weights - -`thin`, `light`, `regular` (default), `bold`, `fill`, `duotone` - -## Finding Icons - -```bash -grep -i ".*Icon" node_modules/@phosphor-icons/react/dist/index.d.ts -``` - -Browse: https://phosphoricons.com - -## Instructions - -1. Search the package for matching icon names using grep (all end with `Icon`) -2. Suggest the most appropriate Phosphor icon(s) -3. Show import and usage example with the `Icon` suffix diff --git a/.claude/skills/react-useeffect b/.claude/skills/react-useeffect new file mode 120000 index 00000000..05a484a5 --- /dev/null +++ b/.claude/skills/react-useeffect @@ -0,0 +1 @@ +../../.agents/skills/react-useeffect \ No newline at end of file diff --git a/.claude/worktrees/fix-styling-issues b/.claude/worktrees/fix-styling-issues deleted file mode 160000 index 0a920155..00000000 --- a/.claude/worktrees/fix-styling-issues +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0a9201555903a44e942d2c22a2994eb47aec0913 diff --git a/.gitignore b/.gitignore index 80545c77..a553016e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,44 @@ # Dependencies -node_modules +node_modules/ +.pnpm-store/ # Build outputs -dist -dist-ssr -.output -.nitro -.vinxi -.vercel +dist/ +dist-ssr/ +.output/ +.nitro/ +.vinxi/ +.vercel/ +coverage/ +*.tsbuildinfo +*.tgz + +# Logs and one-off scratch output +*.log +logs/ +tmp/ +temp/ +.expect/ +.sandcastle/logs/ # Compiled translation catalogs (generated at build time) src/locales/**/messages.js # Framework temp files -.tanstack -.wrangler +.astro/ +.tanstack/ +.wrangler/ # Environment files .env .env.local .env.*.local +*.env.local # Editor/IDE (personal, not shared) -.cursor -.idea +.cursor/ +.idea/ +.vscode/ # OS files .DS_Store diff --git a/.ralph/.env.example b/.ralph/.env.example deleted file mode 100644 index 6e237e5f..00000000 --- a/.ralph/.env.example +++ /dev/null @@ -1 +0,0 @@ -GH_TOKEN= diff --git a/.ralph/.gitignore b/.ralph/.gitignore deleted file mode 100644 index 4c49bd78..00000000 --- a/.ralph/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env diff --git a/.ralph/cleanup.sh b/.ralph/cleanup.sh deleted file mode 100755 index e24e6542..00000000 --- a/.ralph/cleanup.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -set -eo pipefail -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -echo "=== RALPH Cleanup ===" - -# Clean up git worktrees (local mode) -echo "Worktrees:" -worktrees=$(git worktree list --porcelain | grep "^worktree /tmp/ralph-" | cut -d' ' -f2 || true) -if [ -n "$worktrees" ]; then - echo "$worktrees" | while IFS= read -r wt; do - echo " Removing: $wt" - git worktree remove "$wt" --force 2>/dev/null || true - done -else - echo " None." -fi -git worktree prune - -# Clean up docker sandboxes (docker mode) -echo "Docker sandboxes:" -if docker sandbox ls >/dev/null 2>&1; then - sandbox_name="ralph-${REPO_NAME}" - if docker sandbox ls 2>/dev/null | grep -q "$sandbox_name"; then - echo " Removing: $sandbox_name" - docker sandbox rm "$sandbox_name" 2>/dev/null || true - else - echo " None." - fi -else - echo " Docker sandboxes not available (skipped)." -fi - -echo "Done." diff --git a/.ralph/common.sh b/.ralph/common.sh deleted file mode 100755 index e5c96265..00000000 --- a/.ralph/common.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/bash - -RALPH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT=$(git rev-parse --show-toplevel) -REPO_NAME=$(basename "$REPO_ROOT") - -require_env() { - if [ ! -f "$RALPH_DIR/.env" ]; then - echo "Missing .ralph/.env file. Copy the example and fill in your tokens:" - echo " cp .ralph/.env.example .ralph/.env" - echo "" - echo "Required:" - echo " GH_TOKEN - GitHub PAT with 'repo' scope" - exit 1 - fi - - local val - val=$(grep "^GH_TOKEN=" "$RALPH_DIR/.env" | cut -d= -f2-) - if [ -z "$val" ]; then - echo "Missing GH_TOKEN in .ralph/.env" - exit 1 - fi -} - -load_env() { - if [ -f "$RALPH_DIR/.env" ]; then - local val - val=$(grep "^GH_TOKEN=" "$RALPH_DIR/.env" | cut -d= -f2-) - if [ -n "$val" ]; then export GH_TOKEN="$val"; fi - fi -} - -get_iterations() { - if [ -n "$1" ]; then - echo "$1" - else - jq -r '.defaultIterations // 100' "$RALPH_DIR/config.json" - fi -} - -# gh issue list --json emits raw null bytes between records when batching — -# invalid per JSON spec and breaks any downstream `jq` pipe. Strip them. -_gh_issues_clean() { - gh issue list --state open --limit 200 --json number,title,body,comments "$@" | LC_ALL=C tr -d '\000' -} - -# Fetch issues, optionally scoped to a parent PRD issue. -# -# If RALPH_SCOPE is set (e.g. "84" or "#84"): -# - Returns open issues whose body references the scope via the "Parent PRD\n\n#N" convention. -# - If no such children exist, returns just the scope issue itself (stand-alone issue). -# -# If RALPH_SCOPE is unset: returns all open issues (original behavior). -# Interactive PRD picker. Lists open issues whose title starts with "PRD" -# and asks the operator to pick one (or "a" for all unscoped issues). -# -# Skipped when: -# - RALPH_SCOPE is already set -# - RALPH_ALL=1 (user opted into all issues) -# - stdin/stdout is not a TTY (non-interactive invocations like cron) -# - no PRDs are open -pick_prd_scope() { - if [ -n "$RALPH_SCOPE" ] || [ "${RALPH_ALL:-0}" = "1" ]; then - return - fi - if [ ! -t 0 ] || [ ! -t 1 ]; then - return - fi - - # Derive parent issues from the graph: any issue referenced by another - # issue's "## Parent PRD\n\n#N" section is a parent. Then look up each - # parent's title from the same open-issue set. - local all_issues parent_nums prds - all_issues=$(gh issue list --state open --limit 200 --json number,title,body 2>/dev/null | LC_ALL=C tr -d '\000') - # The convention is `## Parent PRD\n\n#N` (blank line separator), so we - # need lines *after* the header — grep -A2 + filter for leading `#N`. - parent_nums=$(printf '%s' "$all_issues" \ - | jq -r '.[].body // ""' 2>/dev/null \ - | grep -A2 'Parent PRD' \ - | grep -oE '^#[0-9]+' \ - | tr -d '#' \ - | sort -u -n) - if [ -z "$parent_nums" ]; then - return - fi - prds=$(printf '%s\n' "$parent_nums" | while read -r n; do - [ -z "$n" ] && continue - title=$(printf '%s' "$all_issues" | jq -r --argjson n "$n" '.[] | select(.number == $n) | .title' 2>/dev/null) - [ -z "$title" ] && continue - printf '%s\t%s\n' "$n" "$title" - done) - - if [ -z "$prds" ]; then - return - fi - - echo "Open PRDs:" >&2 - echo "" >&2 - local i=1 - local numbers=() - while IFS=$'\t' read -r num title; do - [ -z "$num" ] && continue - printf " %d) #%s %s\n" "$i" "$num" "$title" >&2 - numbers+=("$num") - i=$((i+1)) - done <<< "$prds" - printf " a) all open issues (no scope)\n" >&2 - echo "" >&2 - printf "Choose [1-%d/a]: " "${#numbers[@]}" >&2 - - local choice - read -r choice - - if [ -z "$choice" ] || [ "$choice" = "a" ] || [ "$choice" = "A" ]; then - return - fi - if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#numbers[@]}" ]; then - export RALPH_SCOPE="${numbers[$((choice-1))]}" - echo "" >&2 - else - echo "Invalid selection: $choice" >&2 - exit 1 - fi -} - -get_scoped_issues() { - if [ -z "$RALPH_SCOPE" ]; then - _gh_issues_clean - return - fi - - local scope="${RALPH_SCOPE#\#}" - local filter="[.[] | select(.body // \"\" | test(\"Parent PRD[[:space:]]+#${scope}\\\\b\"))]" - local scoped count - scoped=$(_gh_issues_clean --jq "$filter") - # printf '%s' preserves the payload; macOS bash's `echo` mangles some byte sequences and breaks downstream jq. - count=$(printf '%s' "$scoped" | jq 'length' 2>/dev/null || echo 0) - - if [ -z "$count" ] || [ "$count" -eq 0 ]; then - gh issue view "$scope" --json number,title,body,comments --jq '[.]' 2>/dev/null | LC_ALL=C tr -d '\000' || printf '[]' - else - printf '%s' "$scoped" - fi -} diff --git a/.ralph/config.json b/.ralph/config.json deleted file mode 100644 index 95496d79..00000000 --- a/.ralph/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "defaultIterations": 100 -} diff --git a/.ralph/once.sh b/.ralph/once.sh deleted file mode 100755 index 13cdc65e..00000000 --- a/.ralph/once.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -set -eo pipefail -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -require_env -load_env - -# Optional positional shortcut: `.ralph/once.sh 84` → scope to PRD #84 -# `.ralph/once.sh --all` → skip the picker, run against all open issues -if [ "$1" = "--all" ]; then - export RALPH_ALL=1 -elif [ -n "$1" ] && [[ "$1" =~ ^#?[0-9]+$ ]]; then - export RALPH_SCOPE="$1" -fi - -pick_prd_scope - -issues=$(get_scoped_issues) -ralph_commits=$(git log --grep="RALPH" -n 10 --format="%H%n%ad%n%B---" --date=short 2>/dev/null || echo "No RALPH commits found") - -if [ -n "$RALPH_SCOPE" ]; then - echo "Scope: PRD #${RALPH_SCOPE#\#} ($(printf '%s' "$issues" | jq 'length') open sub-issues)" -fi - -claude \ - --model claude-opus-4-6 \ - --settings "$RALPH_DIR/sandbox.json" \ - "$issues Previous RALPH commits: $ralph_commits @.ralph/prompt.md" diff --git a/.ralph/prompt.md b/.ralph/prompt.md deleted file mode 100644 index 66818e91..00000000 --- a/.ralph/prompt.md +++ /dev/null @@ -1,55 +0,0 @@ -# ISSUES - -Issues JSON is provided at start of context. Parse it to get open issues with their bodies and comments. - -You've also been passed the last 10 RALPH commits (SHA, date, full message). Review these to understand what work has been done. - -# TASK SELECTION - -Pick the next task. Prioritize tasks in this order: - -1. Critical bugfixes -2. Tracer bullets for new features - -Tracer bullets comes from the Pragmatic Programmer. When building systems, you want to write code that gets you feedback as quickly as possible. Tracer bullets are small slices of functionality that go through all layers of the system, allowing you to test and validate your approach early. This helps in identifying potential issues and ensures that the overall architecture is sound before investing significant time in development. - -TL;DR - build a tiny, end-to-end slice of the feature first, then expand it out. - -3. Polish and quick wins -4. Refactors - -## Respect blockers - -Each issue body may include a "Blocked by #N" section. If an issue is blocked by another issue that is still in the issues JSON (still open), skip it and pick an unblocked task instead. Only work on an issue once all its blockers are closed (absent from the issues JSON). If every open issue is blocked by another open issue in the list, treat it as a cycle and pick the topologically earliest one. - -Do NOT output COMPLETE unless there are ZERO open issues remaining. After completing your single task, just commit and close the issue — the loop will call you again for the next one. Only output COMPLETE when every issue is closed. - -# EXPLORATION - -Explore the repo and fill your context window with relevant information that will allow you to complete the task. - -# EXECUTION - -Complete the task. - -# COMMIT - -Make a git commit. The commit message must: - -1. Start with `RALPH:` prefix -2. Include task completed + PRD reference -3. Key decisions made -4. Files changed -5. Blockers or notes for next iteration - -Keep it concise. One line only. NEVER add Co-Authored-By lines. - -# THE ISSUE - -If the task is complete, close the original GitHub issue. - -If the task is not complete, leave a comment on the GitHub issue with what was done. - -# FINAL RULES - -ONLY WORK ON A SINGLE TASK. diff --git a/.ralph/run.sh b/.ralph/run.sh deleted file mode 100755 index 21352ec5..00000000 --- a/.ralph/run.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash -set -eo pipefail -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -require_env -load_env - -ITERATIONS=$(get_iterations "$1") -echo "Starting RALPH loop: $ITERATIONS iterations" -echo "" - -# jq filter to extract streaming text from assistant messages -stream_text='select(.type == "assistant").message.content[]? | select(.type == "text").text // empty | gsub("\n"; "\r\n") | . + "\r\n\n"' - -# jq filter to extract final result -final_result='select(.type == "result").result // empty' - -for ((i=1; i<=ITERATIONS; i++)); do - echo "========================================" - echo "Iteration $i / $ITERATIONS" - echo "========================================" - - tmpfile=$(mktemp) - - issues=$(get_scoped_issues) - issue_count=$(printf '%s' "$issues" | jq 'length') - - if [ "$issue_count" -eq 0 ]; then - if [ -n "$RALPH_SCOPE" ]; then - echo "No open issues under scope #${RALPH_SCOPE#\#}. Nothing to do." - else - echo "No open issues. Nothing to do." - fi - exit 0 - fi - - if [ -n "$RALPH_SCOPE" ]; then - echo "Open issues (scope #${RALPH_SCOPE#\#}): $issue_count" - else - echo "Open issues: $issue_count" - fi - - ralph_commits=$(git log --grep="RALPH" -n 10 --format="%H%n%ad%n%B---" --date=short 2>/dev/null || echo "No RALPH commits found") - - # Ensure GH_TOKEN is available to claude subprocess for gh issue close - ENABLE_SECURITY_REMINDER=0 \ - GH_TOKEN="$GH_TOKEN" \ - claude \ - --verbose \ - --print \ - --model claude-opus-4-6 \ - --dangerously-skip-permissions \ - --output-format stream-json \ - --settings "$RALPH_DIR/sandbox.json" \ - "$issues Previous RALPH commits: $ralph_commits @.ralph/prompt.md" \ - | grep --line-buffered '^{' \ - | tee "$tmpfile" \ - | jq --unbuffered -rj "$stream_text" - - # Check for COMPLETE in both the result event and the full text output - result=$(jq -r "$final_result" "$tmpfile") - all_text=$(jq -r 'select(.type == "assistant").message.content[]? | select(.type == "text").text // empty' "$tmpfile") - - rm -f "$tmpfile" - - if [[ "$result" == *"COMPLETE"* ]] || [[ "$all_text" == *"COMPLETE"* ]]; then - echo "" - echo "RALPH complete after $i iterations." - exit 0 - fi - - echo "" -done - -echo "Finished $ITERATIONS iterations." diff --git a/.ralph/sandbox.json b/.ralph/sandbox.json deleted file mode 100644 index 8058b109..00000000 --- a/.ralph/sandbox.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "sandbox": { - "enabled": false - }, - "permissions": { - "defaultMode": "bypassPermissions", - "allow": [ - "*" - ] - }, - "model": "claude-opus-4-6" -} diff --git a/.ralph/setup.sh b/.ralph/setup.sh deleted file mode 100755 index eb41c486..00000000 --- a/.ralph/setup.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -set -eo pipefail -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -require_env -load_env - -echo "=== RALPH Setup ===" -echo "Repo: $REPO_NAME" -echo "" - -# 1. Verify Claude Code is installed -if ! command -v claude >/dev/null 2>&1; then - echo "Claude Code CLI not found. Install it:" - echo " curl -fsSL https://claude.ai/install.sh | bash" - exit 1 -fi -echo "Claude Code: $(claude --version)" - -# 2. Verify Claude authentication -auth_status=$(claude auth status 2>&1 || true) -if echo "$auth_status" | grep -qi "logged in\|authenticated\|active\|max"; then - echo "Auth: $(echo "$auth_status" | grep -i "subscriptionType\|authMethod\|email" | head -3)" -else - echo "Not authenticated. Logging in..." - claude auth login -fi - -# 3. Verify GitHub CLI -if ! gh auth status >/dev/null 2>&1; then - echo "" - echo "GitHub CLI not authenticated. Run:" - echo " gh auth login" - exit 1 -fi -echo "GitHub: authenticated" - -# 4. Check Docker sandboxes (optional) -echo "" -if docker sandbox ls >/dev/null 2>&1; then - echo "Docker sandboxes: available" -else - echo "Docker sandboxes: not available (docker mode won't work)" - echo " Enable in Docker Desktop → Settings → Features in development" -fi - -echo "" -echo "=== Setup complete ===" -echo "" -echo "Usage:" -echo " .ralph/start.sh # local sandbox, 100 iterations" -echo " .ralph/start.sh local 5 # local sandbox, 5 iterations" -echo " .ralph/start.sh local 5 my-branch # local, 5 iterations, custom branch" -echo " .ralph/start.sh docker 5 # docker sandbox, 5 iterations" -echo " .ralph/start.sh docker 5 my-branch # docker, 5 iterations, custom branch" -echo "" -echo "Interactive (single task, you watch):" -echo " .ralph/once.sh" -echo "" -echo "Clean up:" -echo " .ralph/cleanup.sh" diff --git a/.ralph/start.sh b/.ralph/start.sh deleted file mode 100755 index 3acd8914..00000000 --- a/.ralph/start.sh +++ /dev/null @@ -1,222 +0,0 @@ -#!/bin/bash -set -eo pipefail -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -require_env -load_env - -# Parse args: start.sh [local|docker] [iterations] [branch] -# Scoping: -# - `RALPH_SCOPE=84 .ralph/start.sh local` or `--scope 84` scopes to PRD #84's sub-issues -# - `--all` runs against all open issues without prompting -# - Otherwise, an interactive PRD picker is shown (TTY only) -MODE="local" -args=() -for a in "$@"; do - case "$a" in - --scope=*) export RALPH_SCOPE="${a#--scope=}" ;; - --scope) _next_is_scope=1 ;; - --all) export RALPH_ALL=1 ;; - *) - if [ "${_next_is_scope:-0}" = "1" ]; then - export RALPH_SCOPE="$a" - _next_is_scope=0 - else - args+=("$a") - fi - ;; - esac -done -if [ "${_next_is_scope:-0}" = "1" ]; then - echo "Warning: --scope requires a value; ignoring." >&2 -fi -unset _next_is_scope -set -- "${args[@]}" - -pick_prd_scope -if [[ "$1" == "local" || "$1" == "docker" ]]; then - MODE="$1"; shift -fi -ITERATIONS=$(get_iterations "$1") -BRANCH="${2:-ralph/auto}" - -echo "=== RALPH ===" -echo "Mode: $MODE" -echo "Iterations: $ITERATIONS" -echo "Branch: $BRANCH" -if [ -n "$RALPH_SCOPE" ]; then - echo "Scope: PRD #${RALPH_SCOPE#\#}" -fi -echo "" - -# ───────────────────────────────────────────────── -# LOCAL MODE: native macOS sandbox + git worktree -# ───────────────────────────────────────────────── -run_local() { - WORKTREE_DIR="/tmp/ralph-${REPO_NAME}-$$" - echo "Worktree: $WORKTREE_DIR" - echo "" - - # Remove any stale worktree that already has this branch checked out - existing_wt=$(git worktree list --porcelain | awk -v b="$BRANCH" ' - /^worktree / { wt=$2 } - /^branch / { if ($2 == "refs/heads/" b) print wt } - ') - if [ -n "$existing_wt" ]; then - echo "Removing stale worktree: $existing_wt" - git worktree remove "$existing_wt" --force 2>/dev/null || true - git worktree prune - fi - - # Create a git worktree for isolation - if git show-ref --verify --quiet "refs/heads/$BRANCH" 2>/dev/null; then - git worktree add "$WORKTREE_DIR" "$BRANCH" - elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH" 2>/dev/null; then - git worktree add "$WORKTREE_DIR" --track "origin/$BRANCH" - else - git worktree add "$WORKTREE_DIR" -b "$BRANCH" - fi - - cleanup() { - echo "" - echo "Cleaning up worktree..." - cd "$REPO_ROOT" - git worktree remove "$WORKTREE_DIR" --force 2>/dev/null || true - } - trap cleanup EXIT - - cd "$WORKTREE_DIR" - - # Copy .env into worktree (gitignored, not part of worktree) - cp "$RALPH_DIR/.env" "$WORKTREE_DIR/.ralph/.env" - - # Configure git for Ralph commits - git config user.name 'RALPH' - git config user.email 'ralph@noreply' - - # Install dependencies - echo "Installing dependencies..." - pnpm install --frozen-lockfile 2>/dev/null || pnpm install 2>/dev/null || npm install 2>/dev/null || true - echo "" - - # Run the RALPH loop using the main checkout's runner ($RALPH_DIR), not the - # worktree's potentially stale copy. The worktree's ralph/auto branch can be - # behind main and ship an older .ralph/run.sh that doesn't honor RALPH_SCOPE. - "$RALPH_DIR/run.sh" "$ITERATIONS" - - # Push results - push_results -} - -# ───────────────────────────────────────────────── -# DOCKER MODE: Docker Desktop sandbox (microVM) -# ───────────────────────────────────────────────── -run_docker() { - # Check docker sandbox is available - if ! docker sandbox ls >/dev/null 2>&1; then - echo "Docker sandboxes not available." - echo "Enable in Docker Desktop: Settings → Features in development → Docker Sandboxes" - exit 1 - fi - - # Find existing sandbox for this workspace - SANDBOX_NAME=$(docker sandbox ls 2>/dev/null | awk -v ws="$REPO_ROOT" '$0 ~ ws {print $1}') - - if [ -n "$SANDBOX_NAME" ]; then - echo "Using sandbox: $SANDBOX_NAME" - else - SANDBOX_NAME="ralph-${REPO_NAME}" - echo "Creating sandbox: $SANDBOX_NAME" - docker sandbox create claude "$REPO_ROOT" --name "$SANDBOX_NAME" - fi - - # Check if Claude is authenticated inside the sandbox - # If not, run interactive login before starting the loop - if docker sandbox exec "$SANDBOX_NAME" -- claude auth status 2>&1 | grep -qi "not logged in\|not authenticated\|error"; then - echo "" - echo "Claude is not logged in inside the sandbox." - echo "Opening interactive login — authenticate in your browser, then type /exit" - echo "" - docker sandbox run "$SANDBOX_NAME" - echo "" - fi - - echo "" - - # Run the loop — each iteration runs claude in the sandbox by name - for ((i=1; i<=ITERATIONS; i++)); do - echo "========================================" - echo "Iteration $i / $ITERATIONS" - echo "========================================" - - # Fetch issues from host (has gh auth), optionally scoped to RALPH_SCOPE - issues=$(get_scoped_issues) - issue_count=$(printf '%s' "$issues" | jq 'length') - - if [ "$issue_count" -eq 0 ]; then - if [ -n "$RALPH_SCOPE" ]; then - echo "No open issues under scope #${RALPH_SCOPE#\#}. Nothing to do." - else - echo "No open issues. Nothing to do." - fi - break - fi - - if [ -n "$RALPH_SCOPE" ]; then - echo "Open issues (scope #${RALPH_SCOPE#\#}): $issue_count" - else - echo "Open issues: $issue_count" - fi - - ralph_commits=$(git log --grep="RALPH" -n 10 --format="%H%n%ad%n%B---" --date=short 2>/dev/null || echo "No RALPH commits found") - - prompt="$issues Previous RALPH commits: $ralph_commits $(cat "$RALPH_DIR/prompt.md")" - - # Run claude in the existing sandbox, capture output for error detection - output_file=$(mktemp) - docker sandbox run "$SANDBOX_NAME" -- --print "$prompt" 2>&1 | tee "$output_file" || true - - # Check for auth failure — stop and re-login instead of burning iterations - if grep -qi "authentication_error\|Failed to authenticate\|not logged in\|token has expired" "$output_file"; then - rm -f "$output_file" - echo "" - echo "Auth expired. Re-authenticating..." - echo "Log in via browser, then type /exit to resume the loop." - echo "" - docker sandbox run "$SANDBOX_NAME" - echo "Resuming loop..." - # Retry this iteration - ((i--)) - continue - fi - - rm -f "$output_file" - echo "" - done - - # Push from host (workspace syncs between sandbox and host) - push_results -} - -# ───────────────────────────────────────────────── -# Shared: push results -# ───────────────────────────────────────────────── -push_results() { - unpushed=$(git rev-list HEAD --not --remotes 2>/dev/null | wc -l | tr -d ' ') - if [ "$unpushed" -gt 0 ]; then - echo "" - echo "Pushing $unpushed commit(s)..." - git push -u origin "$BRANCH" - else - echo "" - echo "No new commits to push." - fi -} - -# ───────────────────────────────────────────────── -# Run -# ───────────────────────────────────────────────── -case "$MODE" in - local) run_local ;; - docker) run_docker ;; -esac diff --git a/apps/terminal/package.json b/apps/terminal/package.json index 9fa25e6b..d84b9826 100644 --- a/apps/terminal/package.json +++ b/apps/terminal/package.json @@ -64,6 +64,7 @@ "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "5.1.2", + "babel-plugin-macros": "3.1.0", "babel-plugin-react-compiler": "1.0.0", "jsdom": "^29.0.2", "nitro": "3.0.260311-beta", diff --git a/apps/terminal/src/components/pages/not-found-page.tsx b/apps/terminal/src/components/pages/not-found-page.tsx index 21174368..b645e9c1 100644 --- a/apps/terminal/src/components/pages/not-found-page.tsx +++ b/apps/terminal/src/components/pages/not-found-page.tsx @@ -1,4 +1,4 @@ -import { Button } from "@hypeterminal/ui"; +import { buttonVariants } from "@hypeterminal/ui"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -15,10 +15,12 @@ export function NotFoundPage() { The page you are looking for does not exist.

- - + + Go to trading terminal diff --git a/apps/terminal/src/components/trade/chart/chart-panel.tsx b/apps/terminal/src/components/trade/chart/chart-panel.tsx index 93d34df8..04a46f68 100644 --- a/apps/terminal/src/components/trade/chart/chart-panel.tsx +++ b/apps/terminal/src/components/trade/chart/chart-panel.tsx @@ -1,9 +1,11 @@ import { ClientOnly } from "@tanstack/react-router"; import { Suspense, useState } from "react"; +import { getPositionDex } from "@/domain/market"; import { useIntentScriptLoader } from "@/hooks/ui/use-intent-script-loader"; import { TRADINGVIEW_SCRIPT_SRC } from "@/lib/chart/load-tradingview"; import { useSelectedMarketInfo } from "@/lib/hyperliquid"; import { createLazyComponent } from "@/lib/lazy"; +import { useRenderCommitTrack } from "@/lib/performance/render-profile"; import { useTheme } from "@/stores/use-global-settings-store"; const TradingViewChart = createLazyComponent(() => import("./tradingview-chart"), "TradingViewChart"); @@ -12,6 +14,7 @@ const KlineChart = createLazyComponent(() => import("./kline-chart"), "KlineChar type ChartType = "default" | "tradingview"; export function ChartPanel() { + useRenderCommitTrack("chart"); const theme = useTheme(); const { data: selectedMarket } = useSelectedMarketInfo(); const [chartType, setChartType] = useState("default"); @@ -29,6 +32,7 @@ export function ChartPanel() { }> void; + tradingViewIntentHandlers?: ChartSourceToggleIntentHandlers; } -function formatTime(date: Date): string { - return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; -} +const CANDLE_FETCH_RETRY_DELAYS_MS = [750, 2_000] as const; -function formatTooltipDate(date: Date): string { - const m = date.getMonth() + 1; - const d = date.getDate(); - const y = String(date.getFullYear()).slice(2); - return `${m}/${d}/${y} ${formatTime(date)}`; +type CandleSnapshotParams = Parameters["candleSnapshot"]>[0]; + +function delay(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); } -function getOrderLineLabel(order: OpenOrder): string { - if (isTakeProfitOrder(order)) return "TP"; - if (isStopOrder(order)) return "SL"; - return order.side === "B" ? "Limit Buy" : "Limit Sell"; +function logRecoverableCandleFetchFailure(label: string, err: unknown) { + console.warn(`kline ${label} candle fetch failed`, err); } -interface Props { - symbol?: string; - theme?: "light" | "dark"; - yAxisInside?: boolean; - onChartSourceChange?: (source: ChartSource) => void; - tradingViewIntentHandlers?: ChartSourceToggleIntentHandlers; +async function fetchCandlesWithRetry(params: CandleSnapshotParams, shouldContinue: () => boolean) { + let lastError: unknown; + + for (const retryDelayMs of [0, ...CANDLE_FETCH_RETRY_DELAYS_MS]) { + if (!shouldContinue()) return []; + if (retryDelayMs > 0) await delay(retryDelayMs); + if (!shouldContinue()) return []; + + try { + return await getInfoClient().candleSnapshot(params); + } catch (err) { + lastError = err; + } + } + + throw lastError; } export function KlineChart({ symbol = "", + positionDex, theme, yAxisInside = false, onChartSourceChange, @@ -66,26 +67,30 @@ export function KlineChart({ const chartRef = useRef(null); const [activeInterval, setActiveInterval] = useState(DEFAULT_INTERVAL); const [activeChartType, setActiveChartType] = useState(DEFAULT_CHART_TYPE); + const activeCandleType = activeChartType.type; const intervalRef = useRef(activeInterval); intervalRef.current = activeInterval; const isHiddenRef = useRef(false); const hiddenAtRef = useRef(null); const candleBufferRef = useRef([]); - const { address, isConnected } = useConnection(); + const chartStyleRef = useRef({ candleType: activeCandleType, yAxisInside }); + chartStyleRef.current = { candleType: activeCandleType, yAxisInside }; useEffect(() => { const container = containerRef.current; if (!container || !symbol) return; + let disposed = false; registerOrderLineOverlay(); registerPositionLineOverlay(); + registerLiquidationLineOverlay(); const chart = init(container, { customApi: { formatDate: (_dateTimeFormat, timestamp, _format, type) => { const date = new Date(timestamp); if (type === FormatDateType.XAxis) { - if (activeInterval.barMs >= 86_400_000) return formatShortDate(date); + if (activeInterval.barMs >= MS_PER_DAY) return formatShortDate(date); if (date.getHours() === 0 && date.getMinutes() === 0) return formatShortDate(date); return formatTime(date); } @@ -96,25 +101,32 @@ export function KlineChart({ if (!chart) return; chartRef.current = chart; - chart.setStyles(buildKlineStyles(activeChartType.type, { yAxisInside })); - chart.createIndicator("VOL"); + chart.setStyles( + buildKlineStyles(chartStyleRef.current.candleType, { yAxisInside: chartStyleRef.current.yAxisInside }), + ); + chart.createIndicator(VOLUME_INDICATOR_NAME); chart.setLoadDataCallback(({ type, data, callback }) => { const interval = intervalRef.current; if (type === LoadDataType.Forward && data) { const endTime = data.timestamp; - const startTime = endTime - 500 * interval.barMs; + const startTime = endTime - INITIAL_CANDLE_COUNT * interval.barMs; - getInfoClient() - .candleSnapshot({ + fetchCandlesWithRetry( + { coin: symbol, interval: interval.candleInterval, startTime, endTime, - }) + }, + () => !disposed && chartRef.current === chart, + ) .then((candles) => callback(candlesToKLineData(candles), candles.length > 0)) - .catch(() => callback([], false)); + .catch((err) => { + logRecoverableCandleFetchFailure("forward-load", err); + callback([], false); + }); } if (type === LoadDataType.Backward) { @@ -123,34 +135,49 @@ export function KlineChart({ }); const endTime = Date.now(); - const startTime = endTime - 500 * activeInterval.barMs; + const startTime = endTime - INITIAL_CANDLE_COUNT * activeInterval.barMs; - getInfoClient() - .candleSnapshot({ + fetchCandlesWithRetry( + { coin: symbol, interval: activeInterval.candleInterval, startTime, endTime, - }) + }, + () => !disposed && chartRef.current === chart, + ) .then((candles) => { if (chartRef.current === chart) { chart.applyNewData(candlesToKLineData(candles), true); } }) - .catch((err) => console.error("[kline-chart] initial candle fetch failed", err)); + .catch((err) => { + logRecoverableCandleFetchFailure("initial", err); + if (chartRef.current === chart) { + chart.applyNewData([], false); + } + }); const ro = new ResizeObserver(() => chart.resize()); ro.observe(container); return () => { + disposed = true; ro.disconnect(); chartRef.current = null; candleBufferRef.current = []; dispose(container); }; - }, [symbol, activeInterval, activeChartType, yAxisInside, theme]); + }, [symbol, activeInterval]); useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + chart.setStyles(buildKlineStyles(activeCandleType, { yAxisInside, theme })); + }, [activeCandleType, yAxisInside, theme]); + + useEffect(() => { + let disposed = false; isHiddenRef.current = document.visibilityState === "hidden"; candleBufferRef.current = []; @@ -175,23 +202,26 @@ export function KlineChart({ chart.resize(); - if (gapMs >= 30_000 && symbol) { + if (gapMs >= TAB_RESTORE_THRESHOLD_MS && symbol) { const interval = intervalRef.current; const endTime = Date.now(); - const startTime = endTime - 500 * interval.barMs; - getInfoClient() - .candleSnapshot({ coin: symbol, interval: interval.candleInterval, startTime, endTime }) + const startTime = endTime - INITIAL_CANDLE_COUNT * interval.barMs; + fetchCandlesWithRetry( + { coin: symbol, interval: interval.candleInterval, startTime, endTime }, + () => !disposed && chartRef.current === chart, + ) .then((candles) => { if (chartRef.current === chart) { chart.applyNewData(candlesToKLineData(candles), true); } }) - .catch((err) => console.error("[kline-chart] tab-restore candle fetch failed", err)); + .catch((err) => logRecoverableCandleFetchFailure("tab-restore", err)); } } document.addEventListener("visibilitychange", handleVisibility); return () => { + disposed = true; document.removeEventListener("visibilitychange", handleVisibility); candleBufferRef.current = []; }; @@ -218,157 +248,19 @@ export function KlineChart({ } }, [candleData.data]); - const { data: openOrdersEvent } = useSubscription( - "openOrders", - { user: address ?? "0x0", dex: HL_ALL_DEXS }, - { enabled: isConnected && !!address }, - ); - - useEffect(() => { - const chart = chartRef.current; - if (!chart) return; - - chart.removeOverlay({ name: ORDER_LINE_NAME }); - - const orders = openOrdersEvent?.orders; - if (!orders) return; - - const symbolOrders = orders.filter((o) => o.coin === symbol); - - for (const order of symbolOrders) { - const rawPrice = order.isTrigger ? order.triggerPx : order.limitPx; - const price = Big(rawPrice).toNumber(); - if (!Number.isFinite(price)) continue; - - const label = getOrderLineLabel(order); - - chart.createOverlay({ - name: ORDER_LINE_NAME, - points: [{ value: price }], - modeSensitivity: 0, - styles: { - rect: { color: "transparent", borderColor: "transparent", borderSize: 0 }, - polygon: { color: "transparent", borderColor: "transparent", borderSize: 0 }, - }, - extendData: { - side: order.side, - label, - }, - }); - } - }, [openOrdersEvent, symbol]); - - const { data: clearinghouseEvent } = useSubscription( - "allDexsClearinghouseState", - { user: address ?? "" }, - { enabled: isConnected && !!address }, - ); - - useEffect(() => { - const chart = chartRef.current; - if (!chart) return; - - chart.removeOverlay({ name: POSITION_LINE_NAME }); - - const states = clearinghouseEvent?.clearinghouseStates; - if (!states) return; - - const mainDex = states.find(([dex]) => dex === "")?.[1]; - if (!mainDex) return; - - const position = mainDex.assetPositions.find((p) => p.position.coin === symbol); - if (!position) return; - - const entryPxBig = Big(position.position.entryPx); - const sziBig = Big(position.position.szi); - if (sziBig.eq(0)) return; - const entryPx = entryPxBig.toNumber(); - - chart.createOverlay({ - name: POSITION_LINE_NAME, - points: [{ value: entryPx }], - modeSensitivity: 0, - styles: { - rect: { color: "transparent", borderColor: "transparent", borderSize: 0 }, - polygon: { color: "transparent", borderColor: "transparent", borderSize: 0 }, - }, - extendData: { - isLong: sziBig.gt(0), - }, - }); - }, [clearinghouseEvent, symbol]); - - const isNonFavoriteActive = !FAVORITE_SET.has(activeInterval.resolution); - - const showChartSourceToggle = Boolean(onChartSourceChange && tradingViewIntentHandlers); + useKlineOrderOverlays({ chartRef, symbol }); + useKlinePositionOverlays({ chartRef, symbol, dex: positionDex }); return (
-
-
- {STARRED_INTERVALS.map((interval) => ( - - ))} - - {isNonFavoriteActive ? activeInterval.label : null} - - } - items={MORE_INTERVALS.map((interval) => ({ - label: interval.label, - active: activeInterval.resolution === interval.resolution, - onSelect: () => setActiveInterval(interval), - }))} - align="start" - size="sm" - triggerVariant="minimal" - triggerAriaLabel={isNonFavoriteActive ? undefined : t`More intervals`} - className="inline-flex" - popupClassName="min-w-0 w-max" - /> -
- {activeChartType.label}} - items={CHART_TYPES.map((ct) => ({ - label: ct.label, - active: activeChartType.type === ct.type, - onSelect: () => setActiveChartType(ct), - }))} - align="start" - size="sm" - triggerVariant="minimal" - className="inline-flex" - popupClassName="min-w-0 w-max" - /> -
- {showChartSourceToggle ? ( -
- -
- ) : null} -
+
); diff --git a/apps/terminal/src/components/trade/chart/kline-toolbar.tsx b/apps/terminal/src/components/trade/chart/kline-toolbar.tsx new file mode 100644 index 00000000..7c91d15f --- /dev/null +++ b/apps/terminal/src/components/trade/chart/kline-toolbar.tsx @@ -0,0 +1,95 @@ +import { Dropdown } from "@hypeterminal/ui"; +import { t } from "@lingui/core/macro"; +import { CHART_TYPES, type ChartTypeConfig } from "@/config/chart"; +import { FAVORITE_SET, type IntervalConfig, MORE_INTERVALS, STARRED_INTERVALS } from "@/lib/chart/kline-config"; +import { cn } from "@/lib/cn"; +import { type ChartSource, ChartSourceToggle, type ChartSourceToggleIntentHandlers } from "./chart-source-toggle"; + +interface Props { + activeInterval: IntervalConfig; + onIntervalChange: (interval: IntervalConfig) => void; + activeChartType: ChartTypeConfig; + onChartTypeChange: (chartType: ChartTypeConfig) => void; + onChartSourceChange?: (source: ChartSource) => void; + tradingViewIntentHandlers?: ChartSourceToggleIntentHandlers; +} + +export function KlineToolbar({ + activeInterval, + onIntervalChange, + activeChartType, + onChartTypeChange, + onChartSourceChange, + tradingViewIntentHandlers, +}: Props) { + const isNonFavoriteActive = !FAVORITE_SET.has(activeInterval.resolution); + const showChartSourceToggle = Boolean(onChartSourceChange && tradingViewIntentHandlers); + + return ( +
+
+ {STARRED_INTERVALS.map((interval) => ( + + ))} + + {isNonFavoriteActive ? activeInterval.label : null} + + } + items={MORE_INTERVALS.map((interval) => ({ + label: interval.label, + active: activeInterval.resolution === interval.resolution, + onSelect: () => onIntervalChange(interval), + }))} + align="start" + size="sm" + triggerVariant="minimal" + triggerAriaLabel={isNonFavoriteActive ? undefined : t`More intervals`} + className="inline-flex" + popupClassName="min-w-0 w-max" + /> +
+ {activeChartType.label}} + items={CHART_TYPES.map((ct) => ({ + label: ct.label, + active: activeChartType.type === ct.type, + onSelect: () => onChartTypeChange(ct), + }))} + align="start" + size="sm" + triggerVariant="minimal" + className="inline-flex" + popupClassName="min-w-0 w-max" + /> +
+ {showChartSourceToggle && onChartSourceChange && tradingViewIntentHandlers ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/terminal/src/components/trade/chart/token-selector-columns.ts b/apps/terminal/src/components/trade/chart/token-selector-columns.ts index b5f69b56..9cf7de56 100644 --- a/apps/terminal/src/components/trade/chart/token-selector-columns.ts +++ b/apps/terminal/src/components/trade/chart/token-selector-columns.ts @@ -7,6 +7,9 @@ export type MarketScope = "all" | "perp" | "spot" | "hip3"; export type MarketRow = UnifiedMarketInfo; +const TOKEN_SELECTOR_MOBILE_24H_CHANGE_WIDTH = "w-[7.25rem]"; +const TOKEN_SELECTOR_MOBILE_PRICE_WIDTH = "w-[4.75rem]"; + const columnHelper = createColumnHelper(); const numericStringSortFn: SortingFn = (rowA, rowB, columnId) => { @@ -16,6 +19,32 @@ const numericStringSortFn: SortingFn = (rowA, rowB, columnId) => { return a - b; }; +export function getMarketTableColumnClass(columnId: string, mobile: boolean): string { + if (mobile) { + switch (columnId) { + case "24h-change": + return TOKEN_SELECTOR_MOBILE_24H_CHANGE_WIDTH; + case "price": + return TOKEN_SELECTOR_MOBILE_PRICE_WIDTH; + default: + return "w-20"; + } + } + switch (columnId) { + case "price": + return "w-16 sm:w-20"; + case "24h-change": + return "w-28 shrink-0"; + case "oi": + return "w-32 shrink-0"; + case "volume": + case "funding": + return "w-20 sm:w-24 shrink-0"; + default: + return "w-16 sm:w-20"; + } +} + export const TOKEN_SELECTOR_COLUMNS = [ columnHelper.accessor("pairName", { header: t`Market`, diff --git a/apps/terminal/src/components/trade/chart/token-selector-content.tsx b/apps/terminal/src/components/trade/chart/token-selector-content.tsx new file mode 100644 index 00000000..409a80e2 --- /dev/null +++ b/apps/terminal/src/components/trade/chart/token-selector-content.tsx @@ -0,0 +1,274 @@ +import { Button, SearchInput, TableHead, TableHeader, TableRow, tableVariants } from "@hypeterminal/ui"; +import { t } from "@lingui/core/macro"; +import { ArrowDownIcon, ArrowsDownUpIcon, ArrowUpIcon } from "@phosphor-icons/react"; +import { flexRender } from "@tanstack/react-table"; +import { cn } from "@/lib/cn"; +import type { UnifiedMarketInfo } from "@/lib/hyperliquid"; +import { useRenderCommitTrack } from "@/lib/performance/render-profile"; +import { getMarketTableColumnClass, type MarketScope } from "./token-selector-columns"; +import { TokenSelectorRow } from "./token-selector-row"; +import type { useTokenSelector } from "./use-token-selector"; + +function getColumnSortLabel(columnId: string): string { + switch (columnId) { + case "price": + return t`Price`; + case "24h-change": + return t`24h Change`; + case "oi": + return t`Open Interest`; + case "volume": + return t`Volume`; + case "funding": + return t`Funding`; + default: + return columnId; + } +} + +function SortIcon({ columnId, sorting }: { columnId: string; sorting: { id: string; desc: boolean }[] }) { + const sort = sorting.find((s) => s.id === columnId); + if (sort?.desc === false) return ; + if (sort?.desc === true) return ; + return ; +} + +interface Props { + selectedMarket: UnifiedMarketInfo | undefined; + scope: MarketScope; + exchangeScope: string; + exchangeDex: string | undefined; + subcategory: string; + subcategories: { value: string; label: string }[]; + search: string; + setSearch: (value: string) => void; + isLoading: boolean; + isFavorite: (name: string) => boolean; + sorting: { id: string; desc: boolean }[]; + handleSort: (columnId: string) => void; + handleSelect: (name: string) => void; + handleSubcategorySelect: (value: string) => void; + handleScopeSelect: (value: MarketScope) => void; + toggleFavorite: (name: string) => void; + table: ReturnType["table"]; + rows: ReturnType["rows"]; + virtualizer: ReturnType["virtualizer"]; + containerRef: React.RefObject; + filteredMarkets: ReturnType["filteredMarkets"]; + highlightedIndex: number; + headingId: string; + mobile?: boolean; +} + +export function TokenSelectorContent({ + selectedMarket, + scope, + exchangeScope, + exchangeDex, + subcategory, + subcategories, + search, + setSearch, + isLoading, + isFavorite, + sorting, + handleSort, + handleSelect, + handleSubcategorySelect, + handleScopeSelect, + toggleFavorite, + table, + rows, + virtualizer, + containerRef, + filteredMarkets, + highlightedIndex, + headingId, + mobile, +}: Props) { + useRenderCommitTrack("token-search"); + const virtualItems = virtualizer.getVirtualItems(); + const headerGroup = table.getHeaderGroups()[0]; + const marketScopes: { value: MarketScope; label: string }[] = [ + { value: "all", label: t`All` }, + { value: "perp", label: t`Perp` }, + { value: "spot", label: t`Spot` }, + { value: "hip3", label: t`HIP-3` }, + ]; + const showScopeTabs = exchangeScope === "all"; + const showSubcategoryTabs = !exchangeDex && subcategories.length > 0; + const showSelectorFilters = showScopeTabs || showSubcategoryTabs; + + return ( +
+
+

+ {t`Select market`} +

+ setSearch(e.target.value)} + onClear={() => setSearch("")} + size={mobile ? "md" : "sm"} + aria-labelledby={headingId} + /> +
+ + {showSelectorFilters ? ( +
+ {showScopeTabs ? ( +
+ {marketScopes.map((s) => { + const isSelected = scope === s.value; + return ( + + ); + })} +
+ ) : null} + {showSubcategoryTabs ? ( +
+ {subcategories.map((sub) => { + const isSelected = subcategory === sub.value; + return ( + + ); + })} +
+ ) : null} +
+ ) : null} +
+ + + + + {t`Market`} + + {headerGroup?.headers + .filter((h) => h.id !== "pairName") + .map((header) => { + const hiddenOnMobile = ["oi", "volume", "funding"].includes(header.id); + const hideForSpot = scope === "spot" && ["oi", "funding"].includes(header.id); + + if (hideForSpot) return null; + + const sortLabel = getColumnSortLabel(header.id); + return ( + + + + ); + })} + + +
+
+ +
+ {isLoading ? ( +
+ {t`Loading markets...`} +
+ ) : rows.length === 0 ? ( +
{t`No markets found.`}
+ ) : ( +
+ {virtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + const market = row.original; + return ( + = 0 && virtualItem.index === highlightedIndex} + isFavorite={isFavorite(market.name)} + top={virtualItem.start} + height={virtualItem.size} + onSelect={handleSelect} + onToggleFavorite={toggleFavorite} + /> + ); + })} +
+ )} +
+ +
+ + {filteredMarkets.length} {t`markets`} + + + {sorting.length > 0 ? t`Sorted by ${getColumnSortLabel(sorting[0].id)}` : t`Updated live`} + +
+
+ ); +} diff --git a/apps/terminal/src/components/trade/chart/token-selector-popup.tsx b/apps/terminal/src/components/trade/chart/token-selector-popup.tsx new file mode 100644 index 00000000..72c4e8f9 --- /dev/null +++ b/apps/terminal/src/components/trade/chart/token-selector-popup.tsx @@ -0,0 +1,104 @@ +import { DrawerContent } from "@hypeterminal/ui"; +import { PopoverContent } from "@/components/ui/popover"; +import { cn } from "@/lib/cn"; +import type { UnifiedMarketInfo } from "@/lib/hyperliquid"; +import { TokenSelectorContent } from "./token-selector-content"; +import { useTokenSelector } from "./use-token-selector"; + +const TOKEN_SELECTOR_POPOVER_WIDTH = "w-[min(44rem,calc(100vw-1rem))]"; + +export type TokenSelectorPopupProps = { + selectedMarket: UnifiedMarketInfo | undefined; + onValueChange: (value: string) => void; + open: boolean; + onOpenChange: (open: boolean) => void; + mobile?: boolean; + headingId: string; +}; + +export function TokenSelectorPopup({ + selectedMarket, + onValueChange, + open, + onOpenChange, + mobile = false, + headingId, +}: TokenSelectorPopupProps) { + const { + scope, + exchangeScope, + exchangeDex, + subcategory, + subcategories, + search, + setSearch, + isLoading, + isFavorite, + sorting, + handleSort, + handleSelect, + handleSubcategorySelect, + handleScopeSelect, + toggleFavorite, + table, + rows, + virtualizer, + containerRef, + filteredMarkets, + highlightedIndex, + handleKeyDown, + } = useTokenSelector({ + value: selectedMarket?.name ?? "", + onValueChange, + open, + onOpenChange, + }); + + const content = ( + + ); + + if (mobile) { + return {content}; + } + + return ( + + {content} + + ); +} diff --git a/apps/terminal/src/components/trade/chart/token-selector-row.tsx b/apps/terminal/src/components/trade/chart/token-selector-row.tsx new file mode 100644 index 00000000..755c58fe --- /dev/null +++ b/apps/terminal/src/components/trade/chart/token-selector-row.tsx @@ -0,0 +1,161 @@ +import { Badge } from "@hypeterminal/ui"; +import { t } from "@lingui/core/macro"; +import { FireIcon, StarIcon } from "@phosphor-icons/react"; +import { get24hChange, getOiUsd, isTokenInCategory } from "@/domain/market"; +import { cn } from "@/lib/cn"; +import { formatPercent, formatPrice, formatUSD } from "@/lib/format"; +import { getValueColorClass } from "@/lib/ui/value-color"; +import { AssetDisplay } from "../components/asset-display"; +import { getMarketTableColumnClass, type MarketRow, type MarketScope } from "./token-selector-columns"; + +interface Props { + market: MarketRow; + mobile: boolean; + scope: MarketScope; + isSelected: boolean; + isHighlighted: boolean; + isFavorite: boolean; + top: number; + height: number; + onSelect: (name: string) => void; + onToggleFavorite: (name: string) => void; +} + +function getSzDecimals(market: MarketRow): number { + if (market.kind === "spot") return market.tokensInfo[0]?.szDecimals ?? 4; + return market.szDecimals; +} + +function getMaxLeverage(market: MarketRow): number | null { + if (market.kind === "spot") return null; + return market.maxLeverage ?? null; +} + +function getDex(market: MarketRow): string | undefined { + if (market.kind === "builderPerp") return market.dex; + return undefined; +} + +export function TokenSelectorRow({ + market, + mobile, + scope, + isSelected, + isHighlighted, + isFavorite: isFav, + top, + height, + onSelect, + onToggleFavorite, +}: Props) { + const changePercent = get24hChange(market.prevDayPx, market.markPx); + const changeClass = cn( + "font-medium tabular-nums text-xs", + changePercent === null ? "text-fg-muted" : getValueColorClass(changePercent), + ); + const changeText = formatPercent(changePercent !== null ? changePercent / 100 : null); + + const isSpot = market.kind === "spot"; + const isHip3 = market.kind === "builderPerp"; + + const oiValue = getOiUsd(market.openInterest, market.markPx); + + return ( +
onSelect(market.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(market.name); + }} + role="option" + aria-selected={isSelected} + tabIndex={-1} + className={cn( + "flex items-center px-2 cursor-pointer border-b border-stroke-weak", + "hover:bg-fill-hover transition-colors", + "absolute top-0 left-0 w-full", + mobile ? "py-2.5" : "py-1.5", + isSelected && !isHighlighted && "bg-surface", + isHighlighted && "bg-fill-hover", + )} + style={{ height: `${height}px`, transform: `translateY(${top}px)` }} + > +
+ +
+
+ + {market.pairName} + + {isTokenInCategory(market.shortName, "new") && ( + + {t`NEW`} + + )} + +
+
+ {getMaxLeverage(market) && {getMaxLeverage(market)}x} + {isSpot && Spot} + {isHip3 && {getDex(market)}} +
+
+
+
+ + {formatPrice(market.markPx, { szDecimals: getSzDecimals(market) })} + +
+
+ {changeText} +
+ {scope !== "spot" && ( + + )} + + {scope !== "spot" && ( + + )} +
+ ); +} diff --git a/apps/terminal/src/components/trade/chart/token-selector-trigger.tsx b/apps/terminal/src/components/trade/chart/token-selector-trigger.tsx new file mode 100644 index 00000000..c503fe7c --- /dev/null +++ b/apps/terminal/src/components/trade/chart/token-selector-trigger.tsx @@ -0,0 +1,45 @@ +import { Badge } from "@hypeterminal/ui"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import type { UnifiedMarketInfo } from "@/lib/hyperliquid"; +import { AssetDisplay } from "../components/asset-display"; + +export const TOKEN_SELECTOR_TRIGGER_CLASSNAME = + "inline-flex items-center gap-1 max-w-full min-w-0 px-1.5 py-1.5 rounded-8 border border-stroke-weak/50 bg-surface/80 hover:bg-fill-hover transition-colors cursor-pointer leading-none"; + +function getMarketKindBadgeLabel(market: UnifiedMarketInfo | undefined): string { + if (!market) return ""; + if (market.kind === "spot") return "Spot"; + if (market.kind === "builderPerp") return market.dex; + return "Perp"; +} + +interface Props { + selectedMarket: UnifiedMarketInfo | undefined; +} + +export function TokenSelectorTriggerContent({ selectedMarket }: Props) { + const kindBadge = getMarketKindBadgeLabel(selectedMarket); + + return ( + <> + {selectedMarket && ( + <> + + + {selectedMarket.pairName ?? selectedMarket.name} + + {kindBadge ? ( + + {kindBadge} + + ) : null} + + )} + + + ); +} diff --git a/apps/terminal/src/components/trade/chart/token-selector.tsx b/apps/terminal/src/components/trade/chart/token-selector.tsx index ac5cb0cc..af319c57 100644 --- a/apps/terminal/src/components/trade/chart/token-selector.tsx +++ b/apps/terminal/src/components/trade/chart/token-selector.tsx @@ -1,589 +1,36 @@ -import { - Badge, - Button, - Drawer, - DrawerContent, - DrawerTrigger, - SearchInput, - TableHead, - TableHeader, - TableRow, - tableVariants, -} from "@hypeterminal/ui"; +import { Drawer, DrawerTrigger } from "@hypeterminal/ui"; import { t } from "@lingui/core/macro"; -import { ArrowDownIcon, ArrowsDownUpIcon, ArrowUpIcon, CaretDownIcon, FireIcon, StarIcon } from "@phosphor-icons/react"; -import { flexRender } from "@tanstack/react-table"; -import { memo, useId } from "react"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { get24hChange, getOiUsd, isTokenInCategory } from "@/domain/market"; +import { Suspense, useId, useState } from "react"; +import { Popover, PopoverTrigger } from "@/components/ui/popover"; import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/lib/cn"; -import { formatPercent, formatPrice, formatUSD } from "@/lib/format"; import type { UnifiedMarketInfo } from "@/lib/hyperliquid"; -import { getValueColorClass } from "@/lib/trade/numbers"; -import { AssetDisplay } from "../components/asset-display"; -import type { MarketRow, MarketScope } from "./token-selector-columns"; -import { useTokenSelector } from "./use-token-selector"; +import { createLazyComponent } from "@/lib/lazy"; +import { TOKEN_SELECTOR_TRIGGER_CLASSNAME, TokenSelectorTriggerContent } from "./token-selector-trigger"; + +const TokenSelectorPopup = createLazyComponent(() => import("./token-selector-popup"), "TokenSelectorPopup"); export type TokenSelectorProps = { selectedMarket: UnifiedMarketInfo | undefined; onValueChange: (value: string) => void; }; -const marketScopes: { value: MarketScope; label: string }[] = [ - { value: "all", label: "All" }, - { value: "perp", label: "Perp" }, - { value: "spot", label: "Spot" }, - { value: "hip3", label: "HIP-3" }, -]; - -function getColumnSortLabel(columnId: string): string { - switch (columnId) { - case "price": - return t`Price`; - case "24h-change": - return t`24h Change`; - case "oi": - return t`Open Interest`; - case "volume": - return t`Volume`; - case "funding": - return t`Funding`; - default: - return columnId; - } -} - -function getMarketTableColumnClass(columnId: string, mobile: boolean): string { - if (mobile) { - switch (columnId) { - case "24h-change": - return "w-[7.25rem]"; - case "price": - return "w-[4.75rem]"; - default: - return "w-20"; - } - } - switch (columnId) { - case "price": - return "w-16 sm:w-20"; - case "24h-change": - return "w-28 shrink-0"; - case "oi": - return "w-32 shrink-0"; - case "volume": - case "funding": - return "w-20 sm:w-24 shrink-0"; - default: - return "w-16 sm:w-20"; - } -} - -function getSzDecimals(market: MarketRow): number { - if (market.kind === "spot") return market.tokensInfo[0]?.szDecimals ?? 4; - return market.szDecimals; -} - -function getMaxLeverage(market: MarketRow): number | null { - if (market.kind === "spot") return null; - return market.maxLeverage ?? null; -} - -function getDex(market: MarketRow): string | undefined { - if (market.kind === "builderPerp") return market.dex; - return undefined; -} - -interface RowProps { - market: MarketRow; - mobile: boolean; - scope: MarketScope; - isSelected: boolean; - isHighlighted: boolean; - isFavorite: boolean; - top: number; - height: number; - onSelect: (name: string) => void; - onToggleFavorite: (name: string) => void; -} - -function rowPropsEqual(prev: RowProps, next: RowProps): boolean { - return ( - prev.market === next.market && - prev.mobile === next.mobile && - prev.scope === next.scope && - prev.isSelected === next.isSelected && - prev.isHighlighted === next.isHighlighted && - prev.isFavorite === next.isFavorite && - prev.top === next.top && - prev.height === next.height && - prev.onSelect === next.onSelect && - prev.onToggleFavorite === next.onToggleFavorite - ); -} - -const TokenSelectorRow = memo(function TokenSelectorRow({ - market, - mobile, - scope, - isSelected, - isHighlighted, - isFavorite: isFav, - top, - height, - onSelect, - onToggleFavorite, -}: RowProps) { - const changePercent = get24hChange(market.prevDayPx, market.markPx); - const changeClass = cn( - "font-medium tabular-nums", - mobile ? "text-xs" : "text-xs", - changePercent === null ? "text-fg-muted" : getValueColorClass(changePercent), - ); - const changeText = formatPercent(changePercent !== null ? changePercent / 100 : null); - - const isSpot = market.kind === "spot"; - const isHip3 = market.kind === "builderPerp"; - - const oiValue = getOiUsd(market.openInterest, market.markPx); - - return ( -
onSelect(market.name)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") onSelect(market.name); - }} - role="option" - aria-selected={isSelected} - tabIndex={0} - className={cn( - "flex items-center px-2 cursor-pointer border-b border-stroke-weak", - "hover:bg-fill-hover transition-colors", - "absolute top-0 left-0 w-full", - mobile ? "py-2.5" : "py-1.5", - isSelected && !isHighlighted && "bg-surface", - isHighlighted && "bg-fill-hover", - )} - style={{ height: `${height}px`, transform: `translateY(${top}px)` }} - > -
- -
-
- - {market.pairName} - - {isTokenInCategory(market.shortName, "new") && ( - - {t`NEW`} - - )} - -
-
- {getMaxLeverage(market) && {getMaxLeverage(market)}x} - {isSpot && Spot} - {isHip3 && {getDex(market)}} -
-
-
-
- - {formatPrice(market.markPx, { szDecimals: getSzDecimals(market) })} - -
-
- {changeText} -
- {scope !== "spot" && ( - - )} - - {scope !== "spot" && ( - - )} -
- ); -}, rowPropsEqual); - -function getMarketKindBadgeLabel(market: UnifiedMarketInfo | undefined): string { - if (!market) return ""; - if (market.kind === "spot") return "Spot"; - if (market.kind === "builderPerp") return market.dex; - return "Perp"; -} - -function SortIcon({ columnId, sorting }: { columnId: string; sorting: { id: string; desc: boolean }[] }) { - const sort = sorting.find((s) => s.id === columnId); - if (sort?.desc === false) return ; - if (sort?.desc === true) return ; - return ; -} - -interface TokenSelectorContentProps { - selectedMarket: UnifiedMarketInfo | undefined; - scope: MarketScope; - exchangeScope: string; - exchangeDex: string | undefined; - subcategory: string; - subcategories: { value: string; label: string }[]; - search: string; - setSearch: (value: string) => void; - isLoading: boolean; - isFavorite: (name: string) => boolean; - sorting: { id: string; desc: boolean }[]; - handleSort: (columnId: string) => void; - handleSelect: (name: string) => void; - handleSubcategorySelect: (value: string) => void; - handleScopeSelect: (value: MarketScope) => void; - toggleFavorite: (name: string) => void; - table: ReturnType["table"]; - rows: ReturnType["rows"]; - virtualizer: ReturnType["virtualizer"]; - containerRef: React.RefObject; - filteredMarkets: ReturnType["filteredMarkets"]; - highlightedIndex: number; - headingId: string; - mobile?: boolean; -} - -function TokenSelectorContent({ - selectedMarket, - scope, - exchangeScope, - exchangeDex, - subcategory, - subcategories, - search, - setSearch, - isLoading, - isFavorite, - sorting, - handleSort, - handleSelect, - handleSubcategorySelect, - handleScopeSelect, - toggleFavorite, - table, - rows, - virtualizer, - containerRef, - filteredMarkets, - highlightedIndex, - headingId, - mobile, -}: TokenSelectorContentProps) { - const virtualItems = virtualizer.getVirtualItems(); - const headerGroup = table.getHeaderGroups()[0]; - const showScopeTabs = exchangeScope === "all"; - const showSubcategoryTabs = !exchangeDex && subcategories.length > 0; - const showSelectorFilters = showScopeTabs || showSubcategoryTabs; - - return ( -
-
-

- {t`Select market`} -

- setSearch(e.target.value)} - onClear={() => setSearch("")} - size={mobile ? "md" : "sm"} - aria-labelledby={headingId} - /> -
- - {showSelectorFilters ? ( -
- {showScopeTabs ? ( -
- {marketScopes.map((s) => { - const isSelected = scope === s.value; - return ( - - ); - })} -
- ) : null} - {showSubcategoryTabs ? ( -
- {subcategories.map((sub) => { - const isSelected = subcategory === sub.value; - return ( - - ); - })} -
- ) : null} -
- ) : null} -
- - - - - {t`Market`} - - {headerGroup?.headers - .filter((h) => h.id !== "pairName") - .map((header) => { - const hiddenOnMobile = ["oi", "volume", "funding"].includes(header.id); - const hideForSpot = scope === "spot" && ["oi", "funding"].includes(header.id); - - if (hideForSpot) return null; - - const sortLabel = getColumnSortLabel(header.id); - return ( - - - - ); - })} - - -
-
- -
- {isLoading ? ( -
- {t`Loading markets...`} -
- ) : rows.length === 0 ? ( -
{t`No markets found.`}
- ) : ( -
- {virtualItems.map((virtualItem) => { - const row = rows[virtualItem.index]; - const market = row.original; - return ( - = 0 && virtualItem.index === highlightedIndex} - isFavorite={isFavorite(market.name)} - top={virtualItem.start} - height={virtualItem.size} - onSelect={handleSelect} - onToggleFavorite={toggleFavorite} - /> - ); - })} -
- )} -
- -
- - {filteredMarkets.length} {t`markets`} - - - {sorting.length > 0 ? t`Sorted by ${getColumnSortLabel(sorting[0].id)}` : t`Updated live`} - -
-
- ); -} - export function TokenSelector({ selectedMarket, onValueChange }: TokenSelectorProps) { const isMobile = useIsMobile(); const headingId = useId(); - const { - open, - setOpen, - scope, - exchangeScope, - exchangeDex, - subcategory, - subcategories, - search, - setSearch, - isLoading, - isFavorite, - sorting, - handleSort, - handleSelect, - handleSubcategorySelect, - handleScopeSelect, - toggleFavorite, - table, - rows, - virtualizer, - containerRef, - filteredMarkets, - highlightedIndex, - handleKeyDown, - } = useTokenSelector({ value: selectedMarket?.name ?? "", onValueChange }); - - const contentProps = { - selectedMarket, - scope, - exchangeScope, - exchangeDex, - subcategory, - subcategories, - search, - setSearch, - isLoading, - isFavorite, - sorting, - handleSort, - handleSelect, - handleSubcategorySelect, - handleScopeSelect, - toggleFavorite, - table, - rows, - virtualizer, - containerRef, - filteredMarkets, - highlightedIndex, - headingId, - }; - - const kindBadge = getMarketKindBadgeLabel(selectedMarket); - - const trigger = ( - - ); + const [open, setOpen] = useState(false); + const preloadPopup = () => TokenSelectorPopup.preload?.(); + const popup = open ? ( + + + + ) : null; if (isMobile) { return ( @@ -592,54 +39,33 @@ export function TokenSelector({ selectedMarket, onValueChange }: TokenSelectorPr role="combobox" aria-expanded={open} aria-label={t`Select token`} - className="inline-flex items-center gap-1 max-w-full min-w-0 px-1.5 py-1.5 rounded-8 border border-stroke-weak/50 bg-surface/80 hover:bg-fill-hover transition-colors cursor-pointer leading-none" + className={TOKEN_SELECTOR_TRIGGER_CLASSNAME} + onPointerEnter={preloadPopup} + onFocus={preloadPopup} > - {selectedMarket && ( - <> - - - {selectedMarket.pairName ?? selectedMarket.name} - - {kindBadge ? ( - - {kindBadge} - - ) : null} - - )} - + - - - + {popup} ); } return ( - {trigger} - - - + + + + {popup} ); } diff --git a/apps/terminal/src/components/trade/chart/tradingview-chart.tsx b/apps/terminal/src/components/trade/chart/tradingview-chart.tsx index 562bc01f..65c227b3 100644 --- a/apps/terminal/src/components/trade/chart/tradingview-chart.tsx +++ b/apps/terminal/src/components/trade/chart/tradingview-chart.tsx @@ -1,5 +1,12 @@ import { useEffect, useRef } from "react"; +import { CHART_MIN_HEIGHT_PX } from "@/config/chart"; import { loadTradingViewScript } from "@/lib/chart/load-tradingview"; +import { + buildChartOverrides, + generateChartCssUrl, + getLoadingScreenColors, + getToolbarBgColor, +} from "@/lib/chart/theme-colors"; import type { IChartingLibraryWidget, ResolutionString } from "@/types/charting_library"; import { CHART_CUSTOM_FONT_FAMILY, @@ -16,7 +23,6 @@ import { TIMEZONE, } from "./constants"; import { createDatafeed } from "./datafeed"; -import { buildChartOverrides, generateChartCssUrl, getLoadingScreenColors, getToolbarBgColor } from "./theme-colors"; interface Props { symbol?: string; @@ -40,13 +46,14 @@ export function TradingViewChart({ useEffect(() => { if (!containerRef.current) return; + let disposed = false; chartReadyRef.current = false; const initWidget = async () => { try { await loadTradingViewScript(); - if (!containerRef.current || !window.TradingView) return; + if (disposed || !containerRef.current || !window.TradingView) return; if (widgetRef.current) { widgetRef.current.remove(); @@ -60,9 +67,13 @@ export function TradingViewChart({ const loadingColors = getLoadingScreenColors(); const toolbarBg = getToolbarBgColor(); const customCssUrl = await generateChartCssUrl(); + if (disposed || !containerRef.current || !window.TradingView) { + URL.revokeObjectURL(customCssUrl); + return; + } cssUrlRef.current = customCssUrl; - widgetRef.current = new window.TradingView.widget({ + const widget = new window.TradingView.widget({ container: containerRef.current, library_path: CHART_LIBRARY_PATH, datafeed: createDatafeed(), @@ -87,15 +98,16 @@ export function TradingViewChart({ intervals: CHART_FAVORITE_INTERVALS, }, }); + widgetRef.current = widget; - widgetRef.current.onChartReady(() => { + widget.onChartReady(() => { + if (disposed || widgetRef.current !== widget) return; chartReadyRef.current = true; }); if (onSwitchToDefaultRef.current) { - widgetRef.current.headerReady().then(() => { - const widget = widgetRef.current; - if (!widget) return; + widget.headerReady().then(() => { + if (disposed || widgetRef.current !== widget) return; const btn = widget.createButton({ align: "right", useTradingViewStyle: false }); btn.style.cssText = @@ -129,14 +141,15 @@ export function TradingViewChart({ btn.appendChild(tvLabel); }); } - } catch (error) { - console.error("Error initializing TradingView widget:", error); + } catch { + // widgetRef stays null on failure; source toggle then shows default chart } }; initWidget(); return () => { + disposed = true; if (widgetRef.current) { widgetRef.current.remove(); widgetRef.current = null; @@ -150,7 +163,7 @@ export function TradingViewChart({ }, [symbol, interval, theme]); return ( -
+
); diff --git a/apps/terminal/src/components/trade/chart/use-kline-order-overlays.ts b/apps/terminal/src/components/trade/chart/use-kline-order-overlays.ts new file mode 100644 index 00000000..18926dff --- /dev/null +++ b/apps/terminal/src/components/trade/chart/use-kline-order-overlays.ts @@ -0,0 +1,51 @@ +import Big from "big.js"; +import type { Chart } from "klinecharts"; +import { type RefObject, useEffect, useMemo } from "react"; +import { useConnection } from "wagmi"; +import { HL_ALL_DEXS } from "@/config/app"; +import { TRANSPARENT_OVERLAY_STYLES } from "@/lib/chart/kline-styles"; +import { ORDER_LINE_NAME } from "@/lib/chart/order-line-overlay"; +import { useSubscription } from "@/lib/hyperliquid"; +import { getOrderLineLabel } from "@/lib/trade/open-orders"; + +interface Params { + chartRef: RefObject; + symbol: string; +} + +export function useKlineOrderOverlays({ chartRef, symbol }: Params) { + const { address, isConnected } = useConnection(); + + const { data: openOrdersEvent } = useSubscription( + "openOrders", + { user: address ?? "0x0", dex: HL_ALL_DEXS }, + { enabled: isConnected && !!address }, + ); + + const openOrders = openOrdersEvent?.orders; + const symbolOrders = useMemo(() => openOrders?.filter((order) => order.coin === symbol) ?? [], [openOrders, symbol]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart || !symbol) return; + + chart.removeOverlay({ name: ORDER_LINE_NAME }); + + for (const order of symbolOrders) { + const rawPrice = order.isTrigger ? order.triggerPx : order.limitPx; + const price = Big(rawPrice).toNumber(); + if (!Number.isFinite(price)) continue; + + chart.createOverlay({ + name: ORDER_LINE_NAME, + points: [{ value: price }], + modeSensitivity: 0, + styles: TRANSPARENT_OVERLAY_STYLES, + extendData: { + side: order.side, + label: getOrderLineLabel(order), + }, + }); + } + }, [chartRef, symbol, symbolOrders]); +} diff --git a/apps/terminal/src/components/trade/chart/use-kline-position-overlays.ts b/apps/terminal/src/components/trade/chart/use-kline-position-overlays.ts new file mode 100644 index 00000000..1070e9a3 --- /dev/null +++ b/apps/terminal/src/components/trade/chart/use-kline-position-overlays.ts @@ -0,0 +1,60 @@ +import Big from "big.js"; +import type { Chart } from "klinecharts"; +import { type RefObject, useEffect } from "react"; +import { TRANSPARENT_OVERLAY_STYLES } from "@/lib/chart/kline-styles"; +import { LIQUIDATION_LINE_NAME } from "@/lib/chart/liquidation-line-overlay"; +import { POSITION_LINE_NAME } from "@/lib/chart/position-line-overlay"; +import { useUserPositions } from "@/lib/hyperliquid"; + +interface Params { + chartRef: RefObject; + symbol: string; + dex?: string; +} + +export function useKlinePositionOverlays({ chartRef, symbol, dex }: Params) { + const { getPosition } = useUserPositions(); + const position = getPosition(symbol, dex) ?? getPosition(getShortBuilderSymbol(symbol), dex); + const szi = position?.szi; + const entryPx = position?.entryPx; + const liquidationPx = position?.liquidationPx; + + useEffect(() => { + const chart = chartRef.current; + if (!chart || !symbol) return; + + chart.removeOverlay({ name: POSITION_LINE_NAME }); + chart.removeOverlay({ name: LIQUIDATION_LINE_NAME }); + + if (szi == null || entryPx == null) return; + + const sziBig = Big(szi); + if (sziBig.eq(0)) return; + + chart.createOverlay({ + name: POSITION_LINE_NAME, + points: [{ value: Big(entryPx).toNumber() }], + modeSensitivity: 0, + styles: TRANSPARENT_OVERLAY_STYLES, + extendData: { isLong: sziBig.gt(0) }, + }); + + if (liquidationPx != null) { + const liqPxBig = Big(liquidationPx); + if (liqPxBig.gt(0)) { + chart.createOverlay({ + name: LIQUIDATION_LINE_NAME, + points: [{ value: liqPxBig.toNumber() }], + modeSensitivity: 0, + styles: TRANSPARENT_OVERLAY_STYLES, + }); + } + } + }, [chartRef, symbol, szi, entryPx, liquidationPx]); +} + +function getShortBuilderSymbol(symbol: string): string { + const separatorIndex = symbol.indexOf(":"); + if (separatorIndex === -1) return symbol; + return symbol.slice(separatorIndex + 1); +} diff --git a/apps/terminal/src/components/trade/chart/use-token-selector.ts b/apps/terminal/src/components/trade/chart/use-token-selector.ts index 722de2a7..5a0fcfee 100644 --- a/apps/terminal/src/components/trade/chart/use-token-selector.ts +++ b/apps/terminal/src/components/trade/chart/use-token-selector.ts @@ -1,10 +1,12 @@ import { getCoreRowModel, getSortedRowModel, type Row, type SortingState, useReactTable } from "@tanstack/react-table"; import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual"; -import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { TOKEN_SELECTOR_OVERSCAN, TOKEN_SELECTOR_ROW_HEIGHT_PX } from "@/config/layout"; +import { PERP_CATEGORIES } from "@/config/markets"; +import { marketSearchConfig } from "@/config/search"; import { type ExchangeScope, isTokenInCategory, type MarketCategory } from "@/domain/market"; import { useMarketsInfo } from "@/lib/hyperliquid"; import { createSearcher } from "@/lib/search"; -import { marketSearchConfig } from "@/lib/search/presets/market"; import { useExchangeScope } from "@/providers/exchange-scope"; import { useFavoriteMarkets, useMarketActions } from "@/stores/use-market-store"; import { type MarketRow, type MarketScope, TOKEN_SELECTOR_COLUMNS } from "./token-selector-columns"; @@ -17,6 +19,8 @@ export interface Subcategory { export interface UseTokenSelectorOptions { value: string; onValueChange: (value: string) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; } export interface UseTokenSelectorReturn { @@ -46,24 +50,54 @@ export interface UseTokenSelectorReturn { handleKeyDown: (e: React.KeyboardEvent) => void; } -const PERP_CATEGORIES: Subcategory[] = [ - { value: "all", label: "All" }, - { value: "trending", label: "Trending" }, - { value: "new", label: "New" }, - { value: "defi", label: "DeFi" }, - { value: "layer1", label: "L1" }, - { value: "layer2", label: "L2" }, - { value: "meme", label: "Meme" }, -]; - function mapExchangeToMarketScope(es: ExchangeScope): MarketScope { if (es === "builders-perp") return "hip3"; if (es === "perp" || es === "spot") return es; return "all"; } -export function useTokenSelector({ value, onValueChange }: UseTokenSelectorOptions): UseTokenSelectorReturn { - const [open, setOpen] = useState(false); +type MarketsInfo = ReturnType; + +function computeSubcategories( + scope: MarketScope, + spotMarkets: MarketsInfo["spotMarkets"], + builderPerpMarkets: MarketsInfo["builderPerpMarkets"], +): Subcategory[] { + if (scope === "all") return []; + if (scope === "perp") return PERP_CATEGORIES; + + if (scope === "spot") { + const quoteTokens = new Map(); + for (const market of spotMarkets) { + const quoteToken = market.tokensInfo[1]; + if (quoteToken?.name && !quoteTokens.has(quoteToken.name)) { + quoteTokens.set(quoteToken.name, quoteToken.displayName); + } + } + return [ + { value: "all", label: "All" }, + ...Array.from(quoteTokens.entries()).map(([name, displayName]) => ({ + value: name, + label: displayName, + })), + ]; + } + + if (scope === "hip3") { + const dexNames = Object.keys(builderPerpMarkets).filter((k) => k !== "all"); + return [{ value: "all", label: "All" }, ...dexNames.map((d) => ({ value: d, label: d }))]; + } + + return []; +} + +export function useTokenSelector({ + value, + onValueChange, + open: controlledOpen, + onOpenChange, +}: UseTokenSelectorOptions): UseTokenSelectorReturn { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [localScope, setLocalScope] = useState("all"); const [localSubcategory, setLocalSubcategory] = useState("all"); const [search, setSearch] = useState(""); @@ -73,110 +107,92 @@ export function useTokenSelector({ value, onValueChange }: UseTokenSelectorOptio const [highlightedIndex, setHighlightedIndex] = useState(-1); const containerRef = useRef(null); const hasInitializedRef = useRef(false); + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; const { scope: exchangeScope, dex: exchangeDex } = useExchangeScope(); const scope = exchangeScope !== "all" ? mapExchangeToMarketScope(exchangeScope) : localScope; const subcategory = exchangeDex ?? localSubcategory; - const handleSearchChange = useCallback((value: string) => { + function handleSearchChange(value: string) { setSearch(value); startTransition(() => setDeferredSearch(value)); - }, []); + if (open && hasInitializedRef.current) { + setHighlightedIndex(0); + } + } const { markets, spotMarkets, builderPerpMarkets, isLoading } = useMarketsInfo(); const favorites = useFavoriteMarkets(); const { toggleFavoriteMarket } = useMarketActions(); - const favoriteSet = useMemo(() => new Set(favorites), [favorites]); - const isFavorite = useCallback((name: string) => favoriteSet.has(name), [favoriteSet]); - - const subcategories = useMemo((): Subcategory[] => { - if (scope === "all") return []; - if (scope === "perp") return PERP_CATEGORIES; - - if (scope === "spot") { - const quoteTokens = new Map(); - for (const market of spotMarkets) { - const quoteToken = market.tokensInfo[1]; - if (quoteToken?.name && !quoteTokens.has(quoteToken.name)) { - quoteTokens.set(quoteToken.name, quoteToken.displayName); - } - } - return [ - { value: "all", label: "All" }, - ...Array.from(quoteTokens.entries()).map(([name, displayName]) => ({ - value: name, - label: displayName, - })), - ]; - } - - if (scope === "hip3") { - const dexNames = Object.keys(builderPerpMarkets).filter((k) => k !== "all"); - return [{ value: "all", label: "All" }, ...dexNames.map((d) => ({ value: d, label: d }))]; - } + const favoriteSet = new Set(favorites); + function isFavorite(name: string) { + return favoriteSet.has(name); + } - return []; - }, [scope, spotMarkets, builderPerpMarkets]); + const subcategories = computeSubcategories(scope, spotMarkets, builderPerpMarkets); - const handleScopeSelect = useCallback((newScope: MarketScope) => { + function handleScopeSelect(newScope: MarketScope) { setLocalScope(newScope); setLocalSubcategory("all"); - }, []); + if (open && hasInitializedRef.current) { + setHighlightedIndex(0); + } + } + // Semantic ref stability: when the popover is closed, freeze scopeFilteredMarkets to + // the previous reference whenever the membership key (scope|subcategory|names) is unchanged, + // so hidden rows don't re-render on every WS price tick. When open, always pass the live + // reference through so prices update. const stableScopeFilteredRef = useRef<{ key: string; value: MarketRow[] }>({ key: "", value: [] }); - const scopeFilteredMarkets = useMemo(() => { - const filtered = markets.filter((market) => { - if (scope === "perp" && market.kind !== "perp") return false; - if (scope === "spot" && market.kind !== "spot") return false; - if (scope === "hip3" && market.kind !== "builderPerp") return false; - - if (subcategory === "all") return true; + const filteredByScope = markets.filter((market) => { + if (scope === "perp" && market.kind !== "perp") return false; + if (scope === "spot" && market.kind !== "spot") return false; + if (scope === "hip3" && market.kind !== "builderPerp") return false; - if (scope === "perp") { - return isTokenInCategory(market.shortName, subcategory as MarketCategory); - } + if (subcategory === "all") return true; - if (scope === "spot" && market.kind === "spot") { - const quoteToken = market.tokensInfo[1]?.name; - return quoteToken === subcategory; - } - - if (scope === "hip3" && market.kind === "builderPerp") { - return market.dex === subcategory; - } + if (scope === "perp") { + return isTokenInCategory(market.shortName, subcategory as MarketCategory); + } - return true; - }); - // When closed: freeze the list by names-key so hidden rows don't re-render on each WS tick. - // When open: always return the fresh reference so prices update live every ~5s. - if (open) { - stableScopeFilteredRef.current = { - key: `${scope}|${subcategory}|${filtered.length}|${filtered.map((m) => m.name).join(",")}`, - value: filtered, - }; - return filtered; + if (scope === "spot" && market.kind === "spot") { + const quoteToken = market.tokensInfo[1]?.name; + return quoteToken === subcategory; } - const key = `${scope}|${subcategory}|${filtered.length}|${filtered.map((m) => m.name).join(",")}`; - if (stableScopeFilteredRef.current.key === key) { - return stableScopeFilteredRef.current.value; + + if (scope === "hip3" && market.kind === "builderPerp") { + return market.dex === subcategory; } - stableScopeFilteredRef.current = { key, value: filtered }; - return filtered; - }, [markets, scope, subcategory, open]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const searcher = useMemo(() => createSearcher(scopeFilteredMarkets, marketSearchConfig), [scopeFilteredMarkets]); + return true; + }); + const scopeKey = `${scope}|${subcategory}|${filteredByScope.length}|${filteredByScope.map((m) => m.name).join(",")}`; + let scopeFilteredMarkets: MarketRow[]; + if (open) { + stableScopeFilteredRef.current = { key: scopeKey, value: filteredByScope }; + scopeFilteredMarkets = filteredByScope; + } else if (stableScopeFilteredRef.current.key === scopeKey) { + scopeFilteredMarkets = stableScopeFilteredRef.current.value; + } else { + stableScopeFilteredRef.current = { key: scopeKey, value: filteredByScope }; + scopeFilteredMarkets = filteredByScope; + } + + const searcher = createSearcher(scopeFilteredMarkets, marketSearchConfig); - const filteredMarkets = useMemo(() => { - if (!deferredSearch) return scopeFilteredMarkets; + let filteredMarkets: MarketRow[]; + if (!deferredSearch) { + filteredMarkets = scopeFilteredMarkets; + } else { const marketByName = new Map(scopeFilteredMarkets.map((m) => [m.name, m])); - return searcher + filteredMarkets = searcher .search(deferredSearch) .map((result) => marketByName.get(result.item.name)) .filter((m): m is MarketRow => m != null); - }, [scopeFilteredMarkets, searcher, deferredSearch]); + } function handleSort(columnId: string) { setSorting((prev) => { @@ -197,17 +213,20 @@ export function useTokenSelector({ value, onValueChange }: UseTokenSelectorOptio }); const sortedRows = table.getRowModel().rows; + // Semantic: `rows` is used as an effect dep below — keep referential stability so the + // initialization effect doesn't re-run on every render. const rows = useMemo(() => { - const favoriteRows = sortedRows.filter((r) => isFavorite(r.original.name)); - const nonFavoriteRows = sortedRows.filter((r) => !isFavorite(r.original.name)); + const favoriteNames = new Set(favorites); + const favoriteRows = sortedRows.filter((r) => favoriteNames.has(r.original.name)); + const nonFavoriteRows = sortedRows.filter((r) => !favoriteNames.has(r.original.name)); return [...favoriteRows, ...nonFavoriteRows]; - }, [sortedRows, isFavorite]); + }, [sortedRows, favorites]); const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => containerRef.current, - estimateSize: () => 48, - overscan: 10, + estimateSize: () => TOKEN_SELECTOR_ROW_HEIGHT_PX, + overscan: TOKEN_SELECTOR_OVERSCAN, }); useEffect(() => { @@ -225,7 +244,7 @@ export function useTokenSelector({ value, onValueChange }: UseTokenSelectorOptio window.addEventListener("blur", handleWindowBlur); return () => window.removeEventListener("blur", handleWindowBlur); - }, [open]); + }, [open, setOpen]); useEffect(() => { if (!open) { @@ -247,12 +266,6 @@ export function useTokenSelector({ value, onValueChange }: UseTokenSelectorOptio } }, [open, rows, value, virtualizer]); - useEffect(() => { - if (open && hasInitializedRef.current) { - setHighlightedIndex(0); - } - }, [deferredSearch, scope, subcategory]); - function handleKeyDown(e: React.KeyboardEvent) { if (rows.length === 0) return; @@ -285,18 +298,31 @@ export function useTokenSelector({ value, onValueChange }: UseTokenSelectorOptio } } - const handleSelect = useCallback( - (name: string) => { - onValueChange(name); - setOpen(false); + function handleSelect(name: string) { + onValueChange(name); + setOpen(false); + setSearch(""); + setDeferredSearch(""); + } + + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) { setSearch(""); - }, - [onValueChange], - ); + setDeferredSearch(""); + } + } + + function handleSubcategorySelect(next: string) { + setLocalSubcategory(next); + if (open && hasInitializedRef.current) { + setHighlightedIndex(0); + } + } return { open, - setOpen, + setOpen: handleOpenChange, scope, exchangeScope, exchangeDex, @@ -309,7 +335,7 @@ export function useTokenSelector({ value, onValueChange }: UseTokenSelectorOptio sorting, handleSort, handleSelect, - handleSubcategorySelect: setLocalSubcategory, + handleSubcategorySelect, handleScopeSelect, toggleFavorite: toggleFavoriteMarket, table, diff --git a/apps/terminal/src/components/trade/components/asset-badge.tsx b/apps/terminal/src/components/trade/components/asset-badge.tsx new file mode 100644 index 00000000..aa26530b --- /dev/null +++ b/apps/terminal/src/components/trade/components/asset-badge.tsx @@ -0,0 +1,82 @@ +import { Button } from "@hypeterminal/ui"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; +import type { Side } from "@/lib/trade/types"; +import { AssetDisplay } from "./asset-display"; + +interface Props { + coin: string; + side?: Side; + onClick?: () => void; + variant?: "short" | "full"; + subtitle?: ReactNode; + iconUrl?: string; + "aria-label"?: string; + className?: string; + nameClassName?: string; + subtitleClassName?: string; +} + +const sideDotClass: Record = { + buy: "bg-success", + sell: "bg-error", +}; + +const pillClass = + "inline-flex items-center gap-1.5 rounded-full border border-stroke-weak bg-fill-weak px-1.5 py-0.5 leading-none max-w-full"; + +export function AssetBadge({ + coin, + side, + onClick, + variant, + subtitle, + iconUrl, + "aria-label": ariaLabel, + className, + nameClassName, + subtitleClassName, +}: Props) { + const display = ( + + ); + + const sideDot = side ? ( +